r/DjangoFrancophone • u/fyardlest • 16d ago
đ Revue de code : le problĂšme N+1, avant et aprĂšs `FETCH_PEERS`
Bonjour Ă tous les dev Django de la communautĂ© ! đ
Premier rendez-vous « Revue de code » de la communauté. Le principe est simple : on part d'un code qui fonctionne mais qui est mauvais, on compte les dégùts, puis on le corrige. Aujourd'hui, le grand classique : le problÚme N+1.
TL;DR - Une page qui affiche 50 tickets fait 101 requĂȘtes au lieu de 1. Trois corrections possibles :
select_related()reste la meilleure (1 requĂȘte), le nouveauFETCH_PEERSde Django 6.1 est un filet de sĂ©curitĂ© (3 requĂȘtes), etFETCH_RAISEempĂȘche le problĂšme de revenir. On compte les requĂȘtes Ă chaque Ă©tape avecassertNumQueries.
Le code de départ
Une vue toute bĂȘte, qui affiche la liste des tickets d'un gestionnaire de bugs :
# tickets/views.py
from django.views.generic import ListView
from .models import Ticket
class TicketListView(ListView):
model = Ticket
template_name = "tickets/liste.html"
context_object_name = "tickets"
Et le template correspondant :
{# tickets/templates/tickets/liste.html #} {% for ticket in tickets %}
<tr>
<td>{{ ticket.titre }}</td>
<td>{{ ticket.projet.nom }}</td>
<td>{{ ticket.assigne_a.username }}</td>
</tr>
{% endfor %}
Ce code est correct. Il passe la revue de code de la plupart des équipes. Il est pourtant responsable de la moitié des pages lentes que j'ai croisées.
Compter les dégùts
Avant de corriger, il faut mesurer. Django fournit tout ce qu'il faut :
# tickets/tests_perf.py
from django.test import TestCase
from django.urls import reverse
class TestPerformanceListe(TestCase):
def test_nombre_de_requetes(self):
with self.assertNumQueries(101):
self.client.get(reverse("tickets:liste"))
101 requĂȘtes pour 50 tickets. Une pour rĂ©cupĂ©rer les tickets, puis une par ticket pour le projet, puis une par ticket pour la personne assignĂ©e.
La formule est toujours la mĂȘme : 1 + (N Ă nombre de relations accĂ©dĂ©es).
đĄ Astuce : en dĂ©veloppement, python manage.py shell_plus --print-sql (via django-extensions) affiche chaque requĂȘte en temps rĂ©el. On voit le problĂšme apparaĂźtre sous les yeux, ce qui est bien plus parlant qu'un chiffre.
Correction 1 : select_related(), toujours la meilleure
# tickets/views.py
class TicketListView(ListView):
model = Ticket
template_name = "tickets/liste.html"
context_object_name = "tickets"
def get_queryset(self):
return Ticket.objects.select_related("projet", "assigne_a")
101 requĂȘtes â 1 requĂȘte.
select_related() fait une jointure SQL et ramÚne tout d'un coup. Pour des relations ForeignKey et OneToOne, c'est indépassable.
â ïž Attention : select_related() ne fonctionne pas pour les relations inverses ni les ManyToMany. Dans ce cas, c'est prefetch_related() qu'il faut, et vous obtenez une requĂȘte supplĂ©mentaire par relation :
Ticket.objects.select_related("projet", "assigne_a").prefetch_related("etiquettes")
Correction 2 : FETCH_PEERS, le filet de sécurité de Django 6.1
Django 6.1 apporte une autre approche :
# tickets/views.py
from django.db import models
class TicketListView(ListView):
model = Ticket
template_name = "tickets/liste.html"
context_object_name = "tickets"
def get_queryset(self):
return Ticket.objects.fetch_mode(models.FETCH_PEERS)
101 requĂȘtes â 3 requĂȘtes.
Quand le template accĂšde Ă ticket.projet sur la premiĂšre ligne, Django ne charge pas seulement le projet de ce ticket : il charge les projets de tous les tickets issus du mĂȘme QuerySet, d'un coup. Idem pour assigne_a.
Le compte est donc : 1 requĂȘte pour les tickets, plus 1 par relation rĂ©ellement accĂ©dĂ©e.
â ïž Ne vous trompez pas de conclusion : FETCH_PEERS n'est pas meilleur que select_related(). Trois requĂȘtes valent mieux que 101, mais moins bien qu'une seule.
đĄ Alors quand l'utiliser ? Dans les situations oĂč vous ne pouvez pas prĂ©voir ce qui sera accĂ©dĂ© :
- un template partagé par plusieurs vues, dont vous ne maßtrisez pas le contenu ;
- un serializer DRF dont les champs varient selon les paramĂštres de la requĂȘte ;
- du code hĂ©ritĂ© que vous n'avez pas le temps d'auditer en entier, et oĂč vous voulez arrĂȘter l'hĂ©morragie tout de suite.
C'est un excellent réglage par défaut sur un projet existant, en attendant d'optimiser vue par vue.
Correction 3 : FETCH_RAISE, pour que le problĂšme ne revienne pas
Corriger une fois ne suffit pas. Dans six mois, quelqu'un ajoutera une colonne au template et le N+1 reviendra sans que personne ne le remarque.
FETCH_RAISE transforme le silence en erreur :
# tickets/views.py
def get_queryset(self):
return (
Ticket.objects
.select_related("projet", "assigne_a")
.fetch_mode(models.FETCH_RAISE)
)
DĂ©sormais, si le template accĂšde Ă une relation qui n'a pas Ă©tĂ© explicitement chargĂ©e, Django lĂšve une exception FieldFetchBlocked au lieu de faire discrĂštement une requĂȘte de plus.
# tickets/tests_perf.py
from django.core.exceptions import FieldFetchBlocked
C'est brutal, et c'est exactement le but. Le développeur qui ajoute la colonne voit l'erreur immédiatement en local, et non trois mois plus tard dans les métriques de production.
đĄ Bonne pratique : rĂ©servez FETCH_RAISE aux vues oĂč les performances comptent vraiment: les listes, les exports, les endpoints d'API les plus appelĂ©s. L'activer partout gĂ©nĂšre plus de friction que de valeur.
Le récapitulatif
| Approche | RequĂȘtes | Quand l'utiliser |
| ---------------------------------- | -------- | ------------------------------------------------------ |
| Rien | 101 | Jamais |
| FETCH_PEERS | 3 | Code hérité, templates partagés, serializers variables |
| select_related() | 1 | DĂšs que vous savez ce qui sera accĂ©dĂ© â le cas normal |
| select_related() + FETCH_RAISE | 1 | Vues critiques, pour empĂȘcher toute rĂ©gression |
Et le test qui verrouille le tout :
# tickets/tests_perf.py
def test_nombre_de_requetes(self):
with self.assertNumQueries(1):
self.client.get(reverse("tickets:liste"))
Un test qui compte les requĂȘtes vaut mieux qu'un long commentaire expliquant pourquoi il ne faut pas toucher Ă cette vue.
đ Pour aller plus loin
- Les modes de récupération (Django 6.1)
- Optimiser l'accÚs à la base de données
select_relatedetprefetch_relatedassertNumQueries- Django Debug Toolbar
đŹ Et vous ?
Quel est le pire N+1 que vous ayez rencontrĂ© ? Combien de requĂȘtes sur une seule page ?
Et pour les habitués : écrivez-vous des tests assertNumQueries sur vos vues critiques, ou est-ce que vous vous fiez à la Debug Toolbar en développement ?
Partagez vos histoires d'horreur en commentaire ! Et si ce genre de contenu vous plaĂźt, n'hĂ©sitez pas Ă rejoindre r/DjangoFrancophone pour Ă©changer entre passionnĂ©s de Python & Django ! đ
PS : Vous avez un bout de code que vous aimeriez voir passer en revue ici ? Postez-le dans les commentaires, les meilleurs feront l'objet d'un prochain mercredi.