Par défaut, Django charge tous les champs d’un modèle à chaque requête. Sur une vue liste de 50 articles, cela signifie 50 fois le contenu complet, le résumé, les métadonnées, les champs de traduction, même si on n’affiche que le titre et la date. Quatre méthodes QuerySet Django permettent de contrôler précisément ce qui est chargé : defer(), only(), values_list() et Prefetch(). Résultat : 2 requêtes SQL au lieu de N+2, avec uniquement les colonnes nécessaires.
Django defer() : exclure les champs lourds du QuerySet
Django defer() indique à l’ORM d’exclure certains champs de la requête initiale. Les champs exclus restent accessibles sur l’instance, mais chaque accès déclenche une requête supplémentaire.
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField()
content = models.TextField() # champ lourd
excerpt = models.TextField() # champ lourd
is_published = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.CASCADE)
published_at = models.DateTimeField()
# Vue liste : on n'affiche pas le contenu
posts = Post.objects.filter(is_published=True).defer("content", "excerpt")
SQL généré :
SELECT id, title, slug, is_published, author_id, published_at
FROM blog_post
WHERE is_published = true;
Le piège classique : accéder à un champ différé dans une boucle.
# ❌ N+1 silencieux : 1 requête supplémentaire par post
for post in Post.objects.defer("content"):
print(post.content) # déclenche SELECT content FROM blog_post WHERE id = ?
# ✅ Si le contenu est nécessaire, ne pas utiliser defer()
for post in Post.objects.all():
print(post.content)
defer() est utile dans les vues où on contrôle strictement les champs affichés. Dans un sérialiseur qui accède à tout, il devient contre-productif. Voir aussi in_bulk() pour limiter les requêtes N+1 sur les opérations en masse.
Les appels defer() s’accumulent :
Post.objects.defer("content").defer("excerpt")
# → exclut content ET excerpt (équivalent à defer("content", "excerpt"))
Django only() : charger uniquement les champs utiles
only() est l’inverse de defer() : on spécifie les champs à charger, tout le reste est différé. C’est plus explicite quand la liste des champs nécessaires est courte.
# Charger uniquement ce dont on a besoin pour la liste
posts = Post.objects.only("id", "title", "slug", "author_id", "published_at")
SQL généré :
SELECT id, title, slug, author_id, published_at
FROM blog_post;
Contrairement à defer(), les appels only() se remplacent plutôt qu’ils ne s’accumulent :
Post.objects.only("title").only("slug", "author_id")
# → charge uniquement slug et author_id (title est écarté)
Combiner only() avec select_related() reste courant et fonctionne bien :
posts = Post.objects.only("id", "title", "author_id").select_related("author")
# 1 requête avec JOIN, champs minimes, auteur mis en cache
values_list() : quand on n’a pas besoin d’instances
values_list() renvoie des tuples plutôt que des instances de modèle. Aucun objet Python n’est instancié, ce qui évite l’overhead du mapping ORM.
# Tuples (id, title)
Post.objects.filter(is_published=True).values_list("id", "title")
# → <QuerySet [(1, "Premier article"), (2, "Second article"), ...]>
# Valeurs scalaires avec flat=True (un seul champ obligatoire)
Post.objects.filter(is_published=True).values_list("id", flat=True)
# → <QuerySet [1, 2, 3, ...]>
# Named tuples pour un accès par attribut
result = Post.objects.values_list("id", "title", named=True).first()
# result.title ✅
flat=True ne fonctionne qu’avec un seul champ. Sur plusieurs champs, Django lève une TypeError.
Cas d’usage typiques : export CSV, collecte d’IDs pour une opération bulk, alimentation d’un cache Redis. Dès qu’on n’a besoin d’aucune méthode du modèle, values_list() est le bon choix. C’est aussi une bonne paire avec F() dans values() pour renommer les champs ORM.
Les relations peuvent être traversées directement via la notation double underscore. Ni select_related() ni prefetch_related() ne sont nécessaires : Django génère le JOIN automatiquement, et les deux sont silencieusement ignorés avec values() / values_list().
# ✅ Jointure directe, select_related() inutile
Post.objects.values_list("id", "author__username")
# → [(1, "alice"), (2, "alice"), ...]
# select_related() et prefetch_related() sont superflus ici
Post.objects.select_related("author").values_list("id", "author__username")
Post.objects.prefetch_related("author").values_list("id", "author__username")
# Django joint la table dans les deux cas, le prefetch ne s'exécute pas
Attention avec les relations ManyToMany ou les FK inverses : la jointure automatique multiplie les lignes. Si un post a 3 tags, il apparaît 3 fois dans le résultat.
# Post a une M2M vers Tag
Post.objects.values_list("title", "tags__name")
# → [("Article A", "python"), ("Article A", "django"), ("Article A", "orm")]
# ↑ Article A apparaît autant de fois qu'il a de tags
# ✅ Dédupliquer avec distinct()
Post.objects.values_list("title", "tags__name").distinct()
Django Prefetch() : filtrer les relations avec un queryset personnalisé
prefetch_related() seul charge toutes les relations sans filtre. L’objet Prefetch de Django permet de passer un queryset personnalisé pour filtrer, ordonner ou annoter les objets prefetchés, sans toucher aux résultats des autres relations.
from django.db.models import Prefetch
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
body = models.TextField()
is_published = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
Sans Prefetch, tous les commentaires sont chargés, publiés ou non :
posts = Post.objects.prefetch_related("comments")
Avec Prefetch, le filtre est intégré à la requête prefetch :
published_comments = Comment.objects.filter(is_published=True).order_by("-created_at")
posts = Post.objects.prefetch_related(
Prefetch("comments", queryset=published_comments)
)
SQL généré (Django inclut automatiquement le filtre sur les IDs des posts) :
-- Requête 1 : les posts
SELECT * FROM blog_post;
-- Requête 2 : seulement les commentaires publiés, liés aux posts récupérés
SELECT * FROM blog_comment
WHERE is_published = true
AND post_id IN (1, 2, 3, ...)
ORDER BY created_at DESC;
Dans la boucle, le cache prefetch est utilisé :
for post in posts:
print(post.comments.all()) # ✅ cache prefetch utilisé
print(post.comments.filter(...)) # ❌ requête supplémentaire (cache contourné)
Appeler .filter() après le prefetch invalide le cache. Si on a besoin d’un sous-ensemble, le filtre doit être dans le queryset du Prefetch. C’est le même principe que pour select_for_update() dans les transactions concurrentes : l’intention doit être déclarée au niveau de la requête, pas après.
to_attr : stocker le résultat dans un attribut dédié
to_attr stocke le résultat du prefetch dans un attribut Python au lieu d’alimenter le manager de la relation. C’est nécessaire quand on veut plusieurs prefetches sur la même relation, car deux Prefetch sur le même lookup sans to_attr lèvent une ValueError.
from django.utils import timezone
from datetime import timedelta
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.filter(is_published=True),
to_attr="published_comments",
),
Prefetch(
"comments",
queryset=Comment.objects.filter(
created_at__gte=timezone.now() - timedelta(days=7)
),
to_attr="recent_comments",
),
)
for post in posts:
print(post.published_comments) # ✅ liste Python
print(post.recent_comments) # ✅ liste Python
print(post.comments.all()) # ⚠️ requête (to_attr n'alimente pas le manager)
On peut aussi imbriquer select_related() dans le queryset d’un Prefetch pour éviter les N+1 sur les relations des objets prefetchés :
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.select_related("author").filter(is_published=True),
)
)
for post in posts:
for comment in post.comments.all():
print(comment.author.username) # ✅ aucune requête supplémentaire
Quelle méthode Django ORM choisir ?
| Méthode | Renvoie | À utiliser quand |
|---|---|---|
defer(*fields) | Instances modèle | Exclure quelques champs lourds bien identifiés |
only(*fields) | Instances modèle | La liste des champs utiles est courte |
values_list() | Tuples ou scalaires | Pas besoin des méthodes du modèle |
Prefetch() | Instances modèle | Filtrer ou annoter des relations prefetchées |
Les quatre se combinent. Un queryset courant sur une vue liste :
published_comments = Comment.objects.filter(is_published=True).select_related("author")
posts = (
Post.objects.filter(is_published=True)
.only("id", "title", "slug", "author_id", "published_at")
.select_related("author")
.prefetch_related(Prefetch("comments", queryset=published_comments))
.order_by("-published_at")
)
Résultat : 2 requêtes SQL au lieu de N+2, avec uniquement les colonnes nécessaires.
Mesurer avant d’optimiser avec Django Debug Toolbar
Aucune de ces méthodes n’est utile sans mesure. connection.queries et Django Debug Toolbar sont les outils de référence :
from django.db import connection
posts = list(Post.objects.defer("content"))
print(len(connection.queries)) # Nombre de requêtes exécutées
str(queryset.query) affiche le SQL généré avant évaluation. Utile pour vérifier que defer() ou only() exclut bien les colonnes attendues, et que Prefetch() applique le filtre directement en base.
