Lire EXPLAIN ANALYZE dans PostgreSQL pour les débutants
1. Aperçu
EXPLAIN ANALYZE est l’outil le plus important pour diagnostiquer les requêtes lentes dans PostgreSQL. Cet article vous aide à lire chaque partie de la sortie : type de nœud, cost, rows, actual time, et savoir quand une requête a un problème. Après avoir lu ceci, vous serez en confiance pour lire le plan d’exécution et saurez s’il faut ajouter un index ou modifier la requête.
2. EXPLAIN vs EXPLAIN ANALYZE
EXPLAIN affiche seulement le plan prévu (estimated plan) basé sur les statistiques, sans exécuter la requête. EXPLAIN ANALYZE exécute réellement la requête et mesure le temps réel.
-- Voir seulement le plan prévu, sans exécuter la requête
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- Exécuter vraiment, mesurer le temps réel
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Attention :
EXPLAIN ANALYZEexécute réellement la requête, donc si c’est unUPDATEouDELETE, les données seront modifiées. Utilisez une transaction etROLLBACKsi vous voulez juste tester :
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'SHIPPED' WHERE id = 1;
ROLLBACK;
3. Structure d’un nœud dans le plan
Chaque ligne de la sortie est un nœud — une étape de traitement des données. Exemple avec la table orders :
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders (cost=0.00..1834.00 rows=10 width=72)
(actual time=0.015..12.430 rows=8 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 99992
Planning Time: 0.120 ms
Execution Time: 12.465 ms
Explication de chaque partie :
Seq Scan on orders: type de nœud, ici un parcours séquentiel de toute la table.cost=0.00..1834.00: coût estimé (coût de démarrage .. coût total), unité relative, pas en ms.rows=10: nombre de lignes estimé que le planificateur pense retourner.width=72: taille moyenne de chaque ligne (en octets).actual time=0.015..12.430: temps réel (ms) — du début jusqu’à la première ligne .. jusqu’à la fin du nœud.rows=8 loops=1: nombre de lignes réelles retournées, et combien de fois ce nœud a été exécuté (loops).Rows Removed by Filter: nombre de lignes parcourues mais ne correspondant pas au filtre — un grand nombre ici est un signe de manque d’index.
4. Comparer Seq Scan et Index Scan
4.1. Quand il n’y a pas d’index
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders (cost=0.00..1834.00 rows=10 width=72)
(actual time=0.015..12.430 rows=8 loops=1)
Filter: (customer_id = 42)
Execution Time: 12.465 ms
PostgreSQL doit lire toute la table puis filtrer ligne par ligne. Avec une grande table, c’est la cause principale de ralentissement.
4.2. Après avoir ajouté un index
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Index Scan using idx_orders_customer_id on orders
(cost=0.29..8.31 rows=10 width=72)
(actual time=0.018..0.025 rows=8 loops=1)
Index Cond: (customer_id = 42)
Execution Time: 0.045 ms
Comparaison : le coût baisse de 1834 à 8.31, le temps d’exécution baisse de 12.465 ms à 0.045 ms. C’est un exemple classique montrant comment un index permet à PostgreSQL de sauter directement aux lignes nécessaires, sans parcourir toute la table.
Remarque : PostgreSQL n’utilise pas toujours un index même s’il existe. Si la requête retourne une grande partie de la table (par exemple > 20-30% des lignes), le planificateur choisit généralement
Seq Scancar c’est moins coûteux que les accès aléatoires via l’index puis le retour à la table (random I/O).
5. Lire un plan avec JOIN
Quand une requête contient un join, le plan aura plusieurs nœuds imbriqués. Le nœud enfant s’exécute en premier, son résultat remontant au nœud parent.
EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'VN';
Hash Join (cost=45.50..520.75 rows=200 width=40)
(actual time=1.200..8.900 rows=195 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o
(cost=0.00..420.00 rows=10000 width=12)
(actual time=0.010..3.500 rows=10000 loops=1)
-> Hash (cost=40.00..40.00 rows=440 width=36)
(actual time=1.100..1.100 rows=440 loops=1)
-> Seq Scan on customers c
(cost=0.00..40.00 rows=440 width=36)
(actual time=0.005..0.700 rows=440 loops=1)
Filter: (country = 'VN'::text)
Execution Time: 9.050 ms
Comment lire ceci : Hash Join est le nœud racinaire (root), il reçoit les données de deux nœuds enfants (Seq Scan on orders et Hash contenant Seq Scan on customers). PostgreSQL construit une table de hachage à partir de la table customers filtrée, puis parcourt orders et le confronte à la table de hachage. Le temps actual time du nœud parent inclut déjà le temps des nœuds enfants à l’intérieur.
6. Signaux d’alerte à surveiller
6.1. L’estimation s’écarte largement de la réalité
Seq Scan on orders (cost=0.00..1834.00 rows=10 width=72)
(actual time=0.015..12.430 rows=9500 loops=1)
Le planificateur estime rows=10 mais en réalité il retourne 9500. Un écart aussi important est généralement dû à des statistiques obsolètes. Pour résoudre ce problème :
ANALYZE orders;
Cette commande met à jour les statistiques de distribution des données pour que le planificateur fasse des estimations plus précises.
6.2. Loops anormalement grand (Nested Loop)
Nested Loop (cost=0.43..5000.00 rows=1 width=40)
(actual time=0.020..450.000 rows=1 loops=1)
-> Seq Scan on customers c (actual time=0.010..0.015 rows=1 loops=1)
-> Index Scan on orders o
(actual time=0.002..0.450 rows=1 loops=1000)
loops=1000 signifie que le nœud Index Scan on orders est appelé 1000 fois (correspondant à 1000 lignes du nœud externe). Si chaque appel consomme du temps, le coût total sera très élevé bien que le actual time du nœud considéré isolément semble petit. Multipliez actual time par loops pour estimer le temps réel total du nœud.
6.3. Utiliser BUFFERS pour voir les I/O
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
Index Scan using idx_orders_customer_id on orders
(cost=0.29..8.31 rows=10 width=72)
(actual time=0.018..0.025 rows=8 loops=1)
Index Cond: (customer_id = 42)
Buffers: shared hit=4
Execution Time: 0.045 ms
shared hit=4 signifie que 4 blocs ont été lus depuis le cache (mémoire), sans accès disque. Si vous voyez shared read large, cela signifie que les données doivent être lues depuis le disque — signe de cache miss, peut-être dû à shared_buffers trop petit ou à une requête qui parcourt trop de données froides.
7. Utiliser EXPLAIN dans du code Java/Spring
Lors du debug de performance dans une application Spring Boot, vous pouvez logger le plan directement via JdbcTemplate :
@Service
public class QueryDiagnosticsService {
private final JdbcTemplate jdbcTemplate;
public QueryDiagnosticsService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<String> explainAnalyze(String sql) {
String query = "EXPLAIN (ANALYZE, FORMAT TEXT) " + sql;
return jdbcTemplate.queryForList(query, String.class);
}
}
explainAnalyze prend le SQL original, l’enveloppe avec EXPLAIN (ANALYZE, FORMAT TEXT) et retourne chaque ligne du plan sous forme de String. Utilisez-le dans l’environnement dev/staging pour logger le plan quand vous détectez une requête lente.
@Test
void explainAnalyze_shouldReturnPlanWithExecutionTime() {
QueryDiagnosticsService service = new QueryDiagnosticsService(jdbcTemplate);
List<String> plan = service.explainAnalyze(
"SELECT * FROM orders WHERE customer_id = 42"
);
assertThat(plan)
.isNotEmpty()
.anyMatch(line -> line.contains("Execution Time"));
}
Ce test n’affirme pas de chiffres concrets (car le temps varie selon la machine), il vérifie seulement que le plan retourné contient la ligne Execution Time — suffisant pour confirmer que la commande s’est exécutée correctement et a retourné un plan valide.
Attention : Ne pas exécuter
EXPLAIN ANALYZEsur la production pour des requêtes avecINSERT/UPDATE/DELETEsans envelopper dans une transaction avecROLLBACK, car elle s’exécutera réellement.
8. Quand vous n’avez PAS besoin d’EXPLAIN ANALYZE
- La requête est déjà rapide et stable (quelques ms, peu de données) — pas besoin d’optimiser davantage, l’effort ne vaut pas le coup.
- Vous êtes au stade précoce de la conception du schéma, vous n’avez pas assez de données réelles pour que les statistiques reflètent la réalité (peu de lignes signifie que Seq Scan gagne toujours contre Index Scan).
- La requête s’exécute sur une table temporaire (
TEMP TABLE) petite une seule fois — l’overhead d’analyse ne vaut pas les gains.
9. Conclusion
EXPLAIN ANALYZE vous montre la différence entre ce que PostgreSQL pense qu’il va se passer (coût, rows estimés) et ce qui se passe réellement (actual time, rows réels). Quand vous lisez un plan, faites attention à l’ordre d’exécution (de l’intérieur vers l’extérieur), au type de nœud (Seq Scan ou Index Scan), et particulièrement à l’écart entre estimation et réalité — c’est la clé pour savoir si votre requête a besoin d’un index, d’une commande ANALYZE sur la table, ou d’une logique réécrite.