L'ORM de Django est confortable : on manipule des objets Python comme s'ils étaient en mémoire, et les requêtes SQL sont générées en arrière-plan. Cette abstraction est aussi un piège : il est très facile de déclencher des centaines de requêtes sans s'en rendre compte. Le cas le plus classique s'appelle le problème N+1.
C'est quoi une requête N+1 ?
Le nom décrit ce qui se passe : pour récupérer une liste de N éléments, on exécute 1 requête principale, puis N requêtes supplémentaires, une par élément, pour aller chercher des données liées.
Prenons deux modèles classiques :
# models.py
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=120)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
Maintenant, on veut afficher la liste des livres avec le nom de leur auteur :
# views.py
def book_list(request):
books = Book.objects.all()
return render(request, "books.html", {"books": books})
{# books.html #}
<ul>
{% for book in books %}
<li>{{ book.title }} — {{ book.author.name }}</li>
{% endfor %}
</ul>
Pour 100 livres, Django va exécuter :
- 1 requête :
SELECT * FROM book(les 100 livres) - 100 requêtes :
SELECT * FROM author WHERE id = ?(une par livre, à chaque accèsbook.author.namedans le template)
Soit 101 requêtes au lieu d'une seule bien construite.
Pourquoi c'est un problème
Sur une page locale avec 10 lignes, ça ne se voit pas. Le souci apparaît dès qu'on monte en charge ou en volume.
- Latence réseau : chaque requête, même rapide, ajoute un aller-retour avec la base de données. Sur 100 requêtes à 2 ms, on parle de 200 ms juste en attente réseau, à ajouter au temps de rendu.
- Charge sur la base : la base traite 100 fois la même requête au lieu d'une seule jointure. Elle parse, planifie et exécute à chaque fois.
- Mauvais scaling : la performance chute en O(N). Doubler le nombre de livres double le nombre de requêtes. Une page acceptable à 10 utilisateurs devient inutilisable à 1000.
- Effet domino : si le problème vit dans un sérialiseur DRF, chaque entrée
d'une liste paginée d'API en hérite. Une endpoint
/api/books/paginée à 50 peut générer plusieurs centaines de requêtes par appel.
Comment le détecter
On ne corrige bien que ce qu'on mesure. Trois outils utiles, du plus simple au plus complet.
1. connection.queries en développement
Avec DEBUG = True, Django enregistre toutes les requêtes exécutées :
from django.db import connection, reset_queries
reset_queries()
list(Book.objects.all()) # force l'évaluation
for book in Book.objects.all():
_ = book.author.name
print(len(connection.queries)) # 101
Brut, mais immédiat pour confirmer un soupçon depuis un shell.
2. Django Debug Toolbar
C'est la barre flottante qui apparaît en haut à droite des pages en dev. L'onglet SQL liste chaque requête avec son temps, son origine dans le code et les duplicatas regroupés. Si vous voyez « 100 similar queries », c'est un N+1.
3. assertNumQueries dans les tests
L'option robuste, parce qu'elle ne dépend pas d'une inspection visuelle :
from django.test import TestCase
class BookListTest(TestCase):
def test_list_uses_single_query(self):
with self.assertNumQueries(1):
list(Book.objects.select_related("author"))
Le test casse si quelqu'un réintroduit un N+1 plus tard. C'est le seul moyen d'éviter la régression dans la durée.
Les solutions
L'ORM fournit deux outils principaux. Ils ne couvrent pas les mêmes cas.
select_related — pour les ForeignKey et OneToOne
select_related génère une jointure SQL : on récupère tout en une seule
requête.
# 1 requête, avec un INNER JOIN
books = Book.objects.select_related("author").all()
for book in books:
print(book.title, book.author.name)
Le SQL ressemble à :
SELECT book.*, author.*
FROM book
INNER JOIN author ON author.id = book.author_id;
C'est la solution naturelle quand la relation est de type plusieurs-vers-un (plusieurs livres pointent vers le même auteur).
prefetch_related — pour les ManyToMany et reverse ForeignKey
Pour une relation un-vers-plusieurs (un auteur, plusieurs livres),
select_related ne marche pas : une jointure dupliquerait l'auteur N fois.
prefetch_related exécute deux requêtes et fait l'assemblage en Python :
# 2 requêtes : 1 pour les auteurs, 1 pour tous leurs livres
authors = Author.objects.prefetch_related("books").all()
for author in authors:
for book in author.books.all():
print(book.title)
Le SQL :
SELECT * FROM author;
SELECT * FROM book WHERE author_id IN (1, 2, 3, ...);
Django regroupe ensuite les livres par author_id côté Python et les attache
aux objets parents.
Prefetch — pour filtrer ou trier le prefetch
Si on veut prefetcher seulement certains livres ou les ordonner :
from django.db.models import Prefetch
recent_books = Book.objects.filter(published_year__gte=2020).order_by("-published_year")
authors = Author.objects.prefetch_related(
Prefetch("books", queryset=recent_books, to_attr="recent_books")
)
for author in authors:
for book in author.recent_books: # liste Python, pas un manager
print(book.title)
to_attr évite d'écraser le manager author.books et range le résultat dans un
attribut dédié.
Pièges courants
Connaître les outils ne suffit pas. Quelques cas où le N+1 revient malgré tout.
Les sérialiseurs DRF
Un ModelSerializer qui expose des champs liés relit la base à chaque ligne.
La parade est de surcharger le queryset du ViewSet :
class BookViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = BookSerializer
queryset = Book.objects.select_related("author").prefetch_related("tags")
C'est l'endroit unique où la stratégie de fetch doit vivre — pas dispersée dans les sérialiseurs.
Les propriétés et méthodes du modèle
Une @property qui fait une requête est invisible au lecteur du template :
class Author(models.Model):
name = models.CharField(max_length=120)
@property
def book_count(self):
return self.books.count() # 1 requête à chaque appel
Sur une liste, {{ author.book_count }} redéclenche un N+1. Solution :
annoter côté queryset.
from django.db.models import Count
authors = Author.objects.annotate(book_count=Count("books"))
Le book_count devient un attribut résolu en SQL, gratuit côté Python.
.only() et .defer() mal placés
.only("title") économise des colonnes — mais si on accède ensuite à un champ
non listé, Django relance une requête par objet pour le récupérer. À utiliser
seulement quand on est sûr de la liste de champs consommés en aval.
Conclusion
Le N+1 n'est pas une fatalité ; c'est un effet de bord prévisible de l'ORM. La discipline tient en trois temps :
- Mesurer avant d'optimiser. La Debug Toolbar suffit en local.
- Choisir l'outil selon la cardinalité de la relation :
select_relatedpour le « vers-un »,prefetch_relatedpour le « vers-plusieurs ». - Verrouiller avec un
assertNumQueriespour éviter la régression silencieuse.
Une fois le réflexe acquis, la plupart des pages tiennent en moins de cinq requêtes, peu importe la taille du jeu de données.