Éviter les requêtes N+1 en Django

9 mai 20268 min de lecture

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ès book.author.name dans 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 :

  1. Mesurer avant d'optimiser. La Debug Toolbar suffit en local.
  2. Choisir l'outil selon la cardinalité de la relation : select_related pour le « vers-un », prefetch_related pour le « vers-plusieurs ».
  3. Verrouiller avec un assertNumQueries pour é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.