select_related() ou prefetch_related() ? Les deux méthodes éliminent des requêtes N+1 dans Django, mais elles ne produisent pas du tout le même SQL. La première ajoute une jointure à la requête principale. La seconde exécute plusieurs requêtes, puis relie les objets en Python. Le bon choix dépend moins du volume de données que du type de relation traversée.
Pour le comprendre, partons d’un modèle volontairement simple :
from django.conf import settings
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
tags = models.ManyToManyField("Tag", related_name="posts")
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="comments",
)
body = models.TextField()
is_public = models.BooleanField(default=True)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Tag(models.Model):
name = models.CharField(max_length=50)
Depuis un Post, author désigne au maximum un utilisateur. En revanche, comments et tags peuvent contenir plusieurs objets. Cette différence de cardinalité dicte presque toujours la méthode à employer.
Le problème N+1 en Django
Cette boucle semble anodine :
posts = Post.objects.all()
for post in posts:
print(post.title, post.author.username)
Django commence par charger les articles :
SELECT id, title, author_id
FROM blog_post;
Puis l’accès à post.author déclenche une requête pour chaque article :
SELECT id, username
FROM auth_user
WHERE id = 12;
SELECT id, username
FROM auth_user
WHERE id = 37;
-- Une nouvelle requête pour chaque article
Avec 100 articles, on obtient 101 requêtes : une pour la liste, puis 100 pour les auteurs. Même si plusieurs articles partagent le même auteur, chaque instance de Post possède son propre cache de relation. Django ne mutualise donc pas automatiquement ces accès.
Ce N+1 peut rester invisible dans une vue. Il apparaît souvent plus loin, dans un template, une propriété de modèle ou un serializer DRF.
select_related() génère une jointure SQL
select_related() charge la relation dans la requête principale :
posts = Post.objects.select_related("author")
for post in posts:
print(post.title, post.author.username)
Pour la ForeignKey non nullable de notre exemple, Django génère une requête proche de celle-ci :
SELECT
post.id,
post.title,
post.author_id,
author.id,
author.username
FROM blog_post AS post
INNER JOIN auth_user AS author
ON post.author_id = author.id;
Les colonnes des deux tables arrivent dans le même résultat SQL. Django construit ensuite le Post et son auteur à partir de chaque ligne. L’accès à post.author ne déclenche plus aucune requête.
Le type de jointure n’est pas toujours INNER JOIN. Si la relation accepte NULL, Django utilise généralement une jointure externe afin de conserver les articles sans auteur :
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
)
LEFT OUTER JOIN auth_user AS author
ON post.author_id = author.id
Les filtres du queryset peuvent aussi influencer la jointure retenue. Il vaut donc mieux observer le SQL généré que mémoriser une forme unique.
Les relations acceptées par select_related()
select_related() suit uniquement les relations qui renvoient au maximum un objet depuis chaque ligne principale :
- une
ForeignKeydans le sens direct ; - un
OneToOneFielddans les deux sens ; - plusieurs relations unitaires chaînées, comme
post.author.profile.
posts = Post.objects.select_related("author__profile")
Django ajoute alors une jointure par table traversée. Cela reste une seule requête, mais le résultat contient davantage de colonnes. Ajouter toutes les relations sans vérifier qu’elles sont utilisées augmente inutilement le transfert réseau et le travail de la base.
select_related() ne peut pas charger comments ou tags. Une jointure sur une collection répéterait les colonnes du Post pour chaque commentaire ou chaque tag, avec un risque de multiplication rapide des lignes.
prefetch_related() exécute plusieurs requêtes
Pour une relation inverse comme Post.comments, on utilise prefetch_related() :
posts = Post.objects.prefetch_related("comments")
for post in posts:
for comment in post.comments.all():
print(comment.body)
Django exécute d’abord la requête principale :
SELECT id, title, author_id
FROM blog_post;
Une fois les identifiants connus, il lance une seconde requête :
SELECT id, post_id, body, is_public, author_id
FROM blog_comment
WHERE post_id IN (1, 2, 3, 4, 5);
Le regroupement ne se fait pas avec un JOIN SQL. Django construit une table de correspondance en Python à partir de comment.post_id, puis alimente le cache de post.comments pour chaque article.
Le nombre de requêtes reste constant : deux requêtes pour 5 articles comme pour 500. En contrepartie, tous les articles et commentaires préchargés sont conservés en mémoire.
Le cas d’une relation ManyToMany
prefetch_related() fonctionne aussi avec Post.tags. Django n’a normalement pas besoin d’une troisième requête pour la table intermédiaire. Il la joint dans la requête des tags :
posts = Post.objects.prefetch_related("tags")
-- Requête 1 : les articles
SELECT id, title, author_id
FROM blog_post;
-- Requête 2 : les tags et leur article associé
SELECT
post_tags.post_id AS _prefetch_related_val_post_id,
tag.id,
tag.name
FROM blog_tag AS tag
INNER JOIN blog_post_tags AS post_tags
ON tag.id = post_tags.tag_id
WHERE post_tags.post_id IN (1, 2, 3, 4, 5);
La colonne supplémentaire post_id permet à Django de rattacher chaque tag aux bons articles.
select_related() ou prefetch_related() : la règle de choix
Posez une seule question : depuis chaque objet du queryset principal, la relation peut-elle renvoyer plusieurs objets ?
| Relation consultée | Cardinalité depuis l’objet | Méthode conseillée | Requêtes typiques |
|---|---|---|---|
post.author | 0 ou 1 | select_related("author") | 1 |
post.profile en OneToOne | 0 ou 1 | select_related("profile") | 1 |
post.comments | 0 à N | prefetch_related("comments") | 2 |
post.tags | 0 à N | prefetch_related("tags") | 2 |
| Collection filtrée | 0 à N | Prefetch() | 2 |
prefetch_related() sait techniquement charger une ForeignKey, mais il utilise alors deux requêtes là où select_related() peut n’en faire qu’une. Cela peut rester pertinent si la table liée contient beaucoup de colonnes ou si la jointure produit un plan coûteux, mais ce choix doit venir d’une mesure, pas d’une règle abstraite.
Si seul l’identifiant de la relation est nécessaire, aucune des deux méthodes ne sert :
for post in Post.objects.all():
print(post.author_id) # Déjà présent sur la ligne du Post
Combiner select_related() et prefetch_related()
Une page affiche souvent l’auteur de chaque article et ses commentaires. Les deux méthodes sont alors complémentaires :
posts = (
Post.objects
.select_related("author")
.prefetch_related("comments")
)
Django exécute seulement deux requêtes :
-- Requête 1 : articles et auteurs avec une jointure
SELECT post.*, author.*
FROM blog_post AS post
INNER JOIN auth_user AS author
ON post.author_id = author.id;
-- Requête 2 : commentaires de tous les articles
SELECT *
FROM blog_comment
WHERE post_id IN (1, 2, 3, 4, 5);
Sans optimisation, cette page aurait produit 1 + N + N requêtes. Avec 100 articles, on passe de 201 requêtes à 2.
Éviter un N+1 dans les objets préchargés
Précharger les commentaires ne précharge pas automatiquement leurs auteurs :
posts = Post.objects.prefetch_related("comments")
for post in posts:
for comment in post.comments.all():
print(comment.author.username) # N+1 sur les auteurs
Une traversée imbriquée fonctionne :
posts = Post.objects.prefetch_related("comments__author")
Elle exécute trois requêtes : articles, commentaires, puis auteurs. Comme Comment.author est une relation unitaire, on peut faire mieux en joignant les auteurs dans la requête des commentaires :
from django.db.models import Prefetch
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.select_related("author"),
)
)
Cette version revient à deux requêtes : une pour les articles, puis une pour les commentaires joints à leurs auteurs. Pour aller plus loin sur les querysets personnalisés, les filtres et to_attr, consultez l’article sur Prefetch(), defer() et only().
Le piège du cache prefetch_related()
Le cache préchargé correspond précisément au queryset de la relation. Un appel à .all() le réutilise :
for post in posts:
list(post.comments.all()) # Aucune nouvelle requête
Mais une nouvelle opération sur le manager construit un autre queryset :
for post in posts:
post.comments.filter(is_public=True) # Nouvelle requête
post.comments.order_by("-id") # Nouvelle requête
Dans une boucle, on recrée alors le N+1 que l’on pensait avoir supprimé. Le filtre doit être appliqué pendant le prefetch :
public_comments = Comment.objects.filter(is_public=True)
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=public_comments,
to_attr="public_comments",
)
)
for post in posts:
for comment in post.public_comments:
print(comment.body)
to_attr stocke ici une liste Python explicite. post.comments.all() continue donc de signifier tous les commentaires, tandis que post.public_comments désigne uniquement le sous-ensemble préchargé.
Voir le vrai SQL exécuté par Django
str(queryset.query) affiche le SQL de la requête principale :
posts = Post.objects.select_related("author")
print(posts.query)
C’est suffisant pour observer le JOIN de select_related(). Ce n’est pas suffisant pour prefetch_related(), car la seconde requête dépend des identifiants renvoyés par la première et n’est construite qu’au moment de l’évaluation.
Pour capturer toutes les requêtes, utilisez Django Debug Toolbar en développement ou CaptureQueriesContext dans un test :
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
posts = Post.objects.prefetch_related("comments")
with CaptureQueriesContext(connection) as captured:
loaded_posts = list(posts)
for post in loaded_posts:
list(post.comments.all())
for query in captured:
print(query["sql"])
Un test de non-régression protège ensuite le nombre de requêtes :
class PostQueryTests(TestCase):
def test_posts_and_comments_use_two_queries(self):
with self.assertNumQueries(2):
posts = list(Post.objects.prefetch_related("comments"))
comments = [list(post.comments.all()) for post in posts]
Le contenu de comments semble inutilisé, mais l’affectation force volontairement l’évaluation de chaque relation dans le bloc mesuré.
Attention aux très grands querysets
prefetch_related() construit généralement une clause IN contenant les identifiants du queryset principal. Avec quelques centaines de lignes, ce comportement est normal. Avec des dizaines de milliers, la requête devient plus lourde à analyser et tous les objets chargés occupent de la mémoire.
La première solution est souvent de paginer. Pour un traitement par lots, iterator() peut précharger chaque bloc séparément sur les versions récentes de Django :
posts = Post.objects.prefetch_related("comments")
for post in posts.iterator(chunk_size=500):
process(post)
Le compromis change : la requête principale reste parcourue par blocs et Django exécute une requête de prefetch par bloc. On limite la mémoire et la taille des clauses IN, mais on augmente le nombre total de requêtes. Il faut donc mesurer avec un volume représentatif.
Ce qu’il faut retenir
select_related() réalise une jointure SQL et convient aux relations unitaires. prefetch_related() exécute des requêtes séparées, puis associe les collections en Python. Elles ne s’opposent pas : un queryset réaliste combine souvent les deux.
La règle la plus fiable reste simple : partez de la cardinalité, observez les requêtes réellement exécutées, puis verrouillez le résultat avec assertNumQueries. Une optimisation ORM utile ne se mesure pas au nombre de méthodes chaînées, mais au SQL et au volume d’objets qu’elle produit.
