Comment lire la sortie de PostgreSQL EXPLAIN ANALYZE

EXPLAIN montre ce que le planificateur a l'intention de faire ; EXPLAIN ANALYZE exécute la requête et montre ce qui s'est réellement passé. Bien lire cette sortie est la compétence au plus fort levier dans le travail de performance PostgreSQL — et elle comporte quelques pièges qui trompent même les développeurs expérimentés.

Vous ne voulez pas faire les calculs à la main ? Collez votre plan dans le EXPLAIN Visualizer — il calcule les temps exclusifs par nœud et signale automatiquement les problèmes courants, directement dans votre navigateur.

L'anatomie d'un nœud de plan

Chaque ligne d'un plan est un nœud dans un arbre. Les lignes remontent depuis les nœuds les plus internes (les plus indentés) vers le haut. Un nœud typique ressemble à ceci :

Index Scan using orders_customer_idx on orders
    (cost=0.43..152.80 rows=42 width=98)
    (actual time=0.031..0.512 rows=38 loops=1)

Deux groupes de nombres, et ils signifient des choses différentes :

Piège n° 1 : tout est par boucle

Lorsqu'un nœud se trouve du côté interne d'une boucle imbriquée (nested loop), il peut s'exécuter des milliers de fois. PostgreSQL rapporte son actual time et ses rows comme une moyenne par exécution, avec loops qui vous indique combien d'exécutions ont eu lieu :

Index Scan using items_order_idx on items
    (actual time=0.005..0.021 rows=3 loops=12000)

Ce nœud n'a pas renvoyé 3 lignes en 0,021 ms. Il a renvoyé environ 36 000 lignes et a consommé environ 250 ms au total (0,021 × 12 000). Multipliez toujours par loops avant de décider si un nœud est bon marché.

Piège n° 2 : les temps sont cumulatifs

Le actual time d'un nœud inclut celui de tous ses enfants. Si un Sort rapporte 900 ms et que le Seq Scan en dessous rapporte 850 ms, le tri lui-même n'a coûté que ~50 ms. Pour trouver où le temps est réellement passé, il vous faut le temps exclusif de chaque nœud : son total moins les totaux de ses enfants. C'est exactement le calcul qui devient fastidieux dans un plan de 40 nœuds — et la principale raison d'être des visualiseurs de plan.

Piège n° 3 : lignes estimées vs réelles

Le diagnostic le plus précieux de toute la sortie est l'écart entre les lignes estimées et réelles. Le plan a été choisi sur la base de l'estimation — si le planificateur attendait 40 lignes et en a obtenu 400 000, chaque décision en aval de ce nœud (stratégie de jointure, dimensionnement de la mémoire, index vs parcours séquentiel) a été prise sur de fausses hypothèses.

Lorsque vous constatez un écart important :

Utilisez BUFFERS. Toujours.

EXPLAIN (ANALYZE, BUFFERS) ajoute une ligne comme :

Buffers: shared hit=1520 read=8943 dirtied=12

Les compteurs de buffers expliquent la différence entre « rapide dans ma session, lent en production » : le même plan est plus lent de plusieurs ordres de grandeur quand ses blocs ne sont pas en cache. Ils rendent aussi le bloat visible — une requête qui lit 50 000 blocs pour renvoyer 100 lignes étroites lit surtout de l'espace mort ou parcourt bien plus qu'elle ne le devrait.

Autres éléments de sortie à connaître

Une checklist de lecture

  1. Trouvez l'Execution Time total en bas — c'est votre budget.
  2. Parcourez l'arbre à la recherche des nœuds dont le temps exclusif (total moins enfants, multiplié par loops) représente une grande part de ce budget. En général, 1 à 3 nœuds dominent.
  3. Pour chaque nœud critique, comparez les lignes estimées et réelles. Un écart de 10× est un problème de planificateur avant d'être un problème de matériel.
  4. Vérifiez BUFFERS : lit-il beaucoup plus de données que le résultat ne le justifie ?
  5. Ce n'est qu'ensuite que vous devez penser aux corrections : statistiques, index, forme de la requête, work_mem — à peu près dans cet ordre de probabilité.
🔍 Lectures connexes : choisir le bon index quand le plan montre un parcours évitable, et VACUUM & bloat quand les compteurs de buffers semblent gonflés par rapport aux lignes renvoyées.
🧯 Erreurs connexes : canceling statement due to statement timeout — la raison habituelle pour laquelle on examine un plan — et out of shared memory quand un plan touche des milliers de partitions.