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éthodeRenvoieÀ utiliser quand
defer(*fields)Instances modèleExclure quelques champs lourds bien identifiés
only(*fields)Instances modèleLa liste des champs utiles est courte
values_list()Tuples ou scalairesPas besoin des méthodes du modèle
Prefetch()Instances modèleFiltrer 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.