Requêtes N+1, index manquants, COUNT(*) et serializers DRF : comment les repérer et les corriger
15 octobre 2026 · 11 min de lecture
Quand un projet Django devient lent, ce n'est presque jamais « Django qui ne tient pas la charge ». Dans les audits que je réalise, ce sont quasiment toujours les mêmes causes, et elles se corrigent sans réécrire l'application.
Cet article les présente dans l'ordre où je les rencontre le plus souvent, avec la façon de les repérer et le code pour les corriger. C'est aussi la méthode que je détaille dans mon livre The Django Admin Handbook (Apress, 2026).
La première règle d'une optimisation Django : ne rien changer avant d'avoir compté les requêtes SQL. Une page lente avec 400 requêtes et une page lente avec 1 requête de 3 secondes ne se soignent pas du tout de la même manière.
django-debug-toolbar
En développement : nombre de requêtes par page, requêtes dupliquées, temps SQL. C'est l'outil qui révèle 80 % des problèmes en quelques minutes.
EXPLAIN ANALYZE (PostgreSQL)
Pour une requête lente isolée : queryset.explain(analyze=True) indique si PostgreSQL parcourt toute la table (Seq Scan) au lieu d'utiliser un index.
Un test qui verrouille le nombre de requêtes
Une fois corrigé, assertNumQueries empêche la régression au prochain ajout de champ.
def test_liste_commandes_requetes(self):
with self.assertNumQueries(3):
self.client.get("/api/commandes/")
Le symptôme : une liste de 50 éléments déclenche 51 requêtes (ou 101, ou 151). Une requête pour la liste, puis une par ligne pour chaque relation affichée. Avec 10 lignes en développement, personne ne le voit. Avec 10 000 lignes en production, la page met plusieurs secondes.
# Avant : 1 requête + 1 par commande pour le client + 1 par commande pour les lignes
for commande in Commande.objects.all():
print(commande.client.nom, commande.lignes.count())
# Après : 2 requêtes au total
commandes = (
Commande.objects
.select_related("client") # ForeignKey / OneToOne : JOIN SQL
.prefetch_related("lignes") # reverse FK / ManyToMany : 1 requête groupée
)
for commande in commandes:
print(commande.client.nom, len(commande.lignes.all()))
Dans le Django Admin, c'est encore plus fréquent : chaque colonne de list_display qui suit une relation déclenche une requête par ligne. La correction tient en une ligne :
@admin.register(Commande)
class CommandeAdmin(admin.ModelAdmin):
list_display = ["reference", "client", "montant", "statut"]
list_select_related = ["client"]
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("lignes")
Piège classique : .count() ou .filter() sur une relation préchargée relance une requête. Utilisez len(obj.relation.all()), ou mieux, une annotation Count("lignes") dans le queryset.
Le symptôme : peu de requêtes, mais une ou deux très lentes. EXPLAIN ANALYZE affiche un Seq Scan sur une grosse table. Django crée automatiquement un index sur les clés primaires et les ForeignKey, mais pas sur les champs que vous filtrez, triez ou recherchez.
Les candidats à vérifier en priorité : les champs de list_filter, l'ordering par défaut, les filtres fréquents de l'API (statut, date de création) et les combinaisons utilisées ensemble.
class Commande(models.Model):
statut = models.CharField(max_length=20)
cree_le = models.DateTimeField(auto_now_add=True)
client = models.ForeignKey(Client, on_delete=models.PROTECT)
class Meta:
ordering = ["-cree_le"]
indexes = [
models.Index(fields=["-cree_le"]),
# filtre + tri utilisés ensemble : index composite
models.Index(fields=["statut", "-cree_le"]),
]
Pour les search_fields de l'admin, Django génère des ILIKE '%terme%' qu'aucun index classique ne sait accélérer. Sur PostgreSQL, un index trigramme (GinIndex avec gin_trgm_ops, extension pg_trgm) règle le problème.
À ne pas faire : indexer tous les champs « au cas où ». Chaque index ralentit les écritures et occupe de l'espace. On indexe ce que les requêtes réelles utilisent, mesuré avec EXPLAIN.
Le symptôme : la liste admin d'une table de plusieurs millions de lignes met plusieurs secondes à s'afficher, même avec les bons index. La cause : la pagination. Pour afficher « page 1 sur 48 000 », Django exécute un SELECT COUNT(*), et sur PostgreSQL un COUNT exact parcourt toute la table.
L'admin en fait même deux : le nombre de résultats filtrés et le nombre total. Le second se désactive directement :
class EvenementAdmin(admin.ModelAdmin):
show_full_result_count = False
Pour la liste non filtrée, on peut remplacer le COUNT exact par l'estimation que PostgreSQL maintient déjà dans ses statistiques :
from django.core.paginator import Paginator
from django.db import connection
from django.utils.functional import cached_property
class EstimatedCountPaginator(Paginator):
@cached_property
def count(self):
query = getattr(self.object_list, "query", None)
if query is None or query.where:
return super().count
with connection.cursor() as cursor:
cursor.execute(
"SELECT reltuples::bigint FROM pg_class WHERE relname = %s",
[self.object_list.model._meta.db_table],
)
row = cursor.fetchone()
estimate = row[0] if row else -1
return estimate if estimate >= 0 else super().count
class EvenementAdmin(admin.ModelAdmin):
paginator = EstimatedCountPaginator
show_full_result_count = False
Le compromis : le total affiché devient approximatif sur la liste non filtrée. Pour une équipe support qui cherche une commande, c'est sans importance ; pour un écran de reporting, on garde le COUNT exact.
Le symptôme : l'API mobile est lente alors que la base de données répond vite. Les serializers DRF sont pratiques, mais ils cachent trois coûts :
Les serializers imbriqués
Chaque serializer imbriqué suit une relation : c'est un N+1 caché, invisible dans le code de la vue.
Les SerializerMethodField qui requêtent
Un get_nb_avis() qui fait un .count() s'exécute pour chaque objet de la liste.
Un seul serializer pour la liste et le détail
La liste renvoie 40 champs par élément alors que l'écran mobile en affiche 4.
class ProduitViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
qs = Produit.objects.select_related("categorie")
if self.action == "list":
# le calcul est fait par SQL, pas une requête par produit
return qs.annotate(nb_avis=Count("avis"))
return qs.prefetch_related("avis__auteur")
def get_serializer_class(self):
if self.action == "list":
return ProduitListSerializer # 4 à 6 champs, nb_avis lu depuis l'annotation
return ProduitDetailSerializer
Règle simple : un serializer ne doit jamais déclencher de requête. Tout ce dont il a besoin doit être préparé dans get_queryset() (select_related, prefetch_related, annotate).
| Problème | Comment le repérer | Correction |
|---|---|---|
| Requêtes N+1 | Nombre de requêtes proportionnel au nombre de lignes | select_related, prefetch_related, list_select_related |
| Index manquants | Seq Scan dans EXPLAIN ANALYZE |
Meta.indexes sur filtres et tris réels, trigramme pour la recherche |
| COUNT(*) lents | Liste admin lente sur table volumineuse malgré les index | show_full_result_count = False, paginator à comptage estimé |
| Serializers DRF lourds | API lente, base de données rapide | Serializers liste/détail séparés, annotate, préchargement dans get_queryset |
Avant d'ajouter des serveurs, du cache ou de réécrire un service, vérifiez ces quatre points. Ils sont responsables de l'essentiel des lenteurs que je rencontre, et leur correction est généralement rapide et sans risque.
Le cache (Redis) et les tâches asynchrones (Celery) viennent ensuite, une fois que chaque requête fait le minimum de travail.
Votre projet Django ou votre admin ralentit ?
J'audite votre code et vos requêtes, et je vous remets un plan de correction priorisé en 7 jours.