Cómo leer la salida de EXPLAIN ANALYZE de PostgreSQL

EXPLAIN muestra lo que el planificador pretende hacer; EXPLAIN ANALYZE ejecuta la consulta y muestra lo que realmente ocurrió. Leer bien la salida es la habilidad de mayor impacto en el trabajo de rendimiento de PostgreSQL, y tiene unas cuantas trampas que atrapan incluso a desarrolladores con experiencia.

¿No quieres hacer la aritmética a mano? Pega tu plan en el EXPLAIN Visualizer: calcula los tiempos exclusivos por nodo y señala los problemas habituales automáticamente, directamente en tu navegador.

La anatomía de un nodo del plan

Cada línea de un plan es un nodo de un árbol. Las filas fluyen desde los nodos más internos (los más indentados) hacia arriba. Un nodo típico tiene este aspecto:

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)

Dos grupos de números, y significan cosas distintas:

Trampa n.º 1: todo es por loop

Cuando un nodo está en el lado interno de un nested loop, puede ejecutarse miles de veces. PostgreSQL informa de su actual time y sus rows como un promedio por ejecución, y loops te dice cuántas ejecuciones hubo:

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

Este nodo no devolvió 3 filas en 0.021 ms. Devolvió aproximadamente 36.000 filas y consumió alrededor de 250 ms en total (0.021 × 12.000). Multiplica siempre por loops antes de decidir si un nodo es barato.

Trampa n.º 2: los tiempos son acumulativos

El actual time de un nodo incluye el de todos sus hijos. Si un Sort informa de 900 ms y el Seq Scan que tiene debajo informa de 850 ms, el sort en sí solo costó ~50 ms. Para averiguar dónde se gasta realmente el tiempo, necesitas el tiempo exclusivo de cada nodo: su total menos los totales de sus hijos. Esta es exactamente la aritmética que se vuelve tediosa en un plan de 40 nodos, y la razón principal por la que existen los visualizadores de planes.

Trampa n.º 3: filas estimadas frente a reales

El diagnóstico más valioso de toda la salida es la diferencia entre las filas estimadas y las reales. El plan se eligió en función de la estimación: si el planificador esperaba 40 filas y obtuvo 400.000, todas las decisiones aguas abajo de ese nodo (estrategia de join, dimensionamiento de memoria, index scan frente a sequential scan) se tomaron sobre supuestos equivocados.

Cuando veas una discrepancia grande:

Usa BUFFERS. Siempre.

EXPLAIN (ANALYZE, BUFFERS) añade una línea como:

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

Los recuentos de buffers explican la diferencia entre "rápido en mi sesión, lento en producción": el mismo plan es órdenes de magnitud más lento cuando sus bloques no están en caché. También hacen visible el bloat: una consulta que lee 50.000 bloques para devolver 100 filas estrechas está leyendo en su mayoría espacio muerto o escaneando mucho más de lo que debería.

Otra salida que conviene conocer

Una checklist de lectura

  1. Busca el Execution Time total al final: ese es tu presupuesto.
  2. Recorre el árbol buscando nodos cuyo tiempo exclusivo (total menos hijos, por loops) sea una parte grande de él. Normalmente dominan 1–3 nodos.
  3. Para cada nodo caliente, compara las filas estimadas frente a las reales. Una diferencia de 10× es un problema del planificador antes que un problema de hardware.
  4. Revisa BUFFERS: ¿está leyendo mucho más datos de los que justifica el resultado?
  5. Solo entonces piensa en soluciones: estadísticas, índices, forma de la consulta, work_mem, más o menos en ese orden de probabilidad.
🔍 Lecturas relacionadas: elegir el índice adecuado cuando el plan muestra un escaneo evitable, y VACUUM y bloat cuando los recuentos de buffers parecen inflados para las filas devueltas.
🧯 Errores relacionados: canceling statement due to statement timeout — el motivo habitual por el que se investiga un plan — y out of shared memory cuando un plan toca miles de particiones.