← maurobernal.com.ar

Cómo leer EXPLAIN ANALYZE en PostgreSQL: guía práctica con ejemplos

Si alguna vez te preguntaste por qué una query en PostgreSQL tarda más de lo esperado, EXPLAIN ANALYZE es tu mejor aliado. En este post vas a aprender a leer un plan de ejecución de principio a fin, con ejemplos reales y sin vueltas.

¿Qué es EXPLAIN ANALYZE?

EXPLAIN muestra el plan de ejecución que el planner de PostgreSQL genera para una query. Cuando le agregás ANALYZE, la query se ejecuta de verdad y te devuelve estadísticas reales junto con las estimaciones del planner.

EXPLAIN ANALYZE SELECT * FROM productos WHERE categoria = 'Electrónica';

⚠️ Importante: EXPLAIN ANALYZE ejecuta la query. Si es un INSERT, UPDATE o DELETE, envolvela en una transacción y hacé rollback para evitar modificar datos reales.

Cómo leer el plan: estructura general

El plan es un árbol invertido. Los nodos más internos (indentados) se ejecutan primero y alimentan a los nodos superiores. Así:

Gather  (cost=10.00..45524.73 rows=949636 width=97) (actual time=0.673..173.017 rows=955000 loops=1)
  Workers Planned: 4
  Workers Launched: 4
  ->  Parallel Seq Scan on test_table  (cost=0.00..44565.09 rows=237409 width=97) (actual time=0.039..51.941 rows=191000 loops=5)
Planning Time: 0.093 ms
Execution Time: 209.745 ms

Leé siempre de adentro hacia afuera: primero el Parallel Seq Scan, luego el Gather que agrupa los resultados.

Los 5 números que tenés que entender

Cada nodo tiene dos grupos de paréntesis: estimaciones y valores reales.

Estimaciones del planner

  • cost=X..Y — Costo de arranque (X) y costo total (Y). Son unidades arbitrarias, no milisegundos. Sirven para comparar planes entre sí.
  • rows=Z — Cantidad de filas que el planner estima que va a devolver ese nodo.
  • width=W — Ancho promedio en bytes de cada fila de salida.

Valores reales (después de ejecutar)

  • actual time=A..B — Tiempo real en ms: A = primera fila devuelta, B = última fila devuelta.
  • rows=C — Filas realmente devueltas por ese nodo en cada ejecución.
  • loops=D — Cuántas veces se ejecutó ese nodo. Multiplicá rows × loops para el total real.

El truco del loops: el número más malentendido

Cuando ves rows=191000 loops=5, no significa 191.000 filas totales. Significa:

Total de filas = 191.000 × 5 = 955.000
Tiempo total de CPU = 52ms × 5 = 260ms  (pero en paralelo, el tiempo de pared es ~52ms)

Regla práctica: filas × loops = filas reales procesadas. Para tiempo paralelo, el reloj de pared es menor que el tiempo acumulado de CPU.

Ejemplo 1: Seq Scan (sin índice)

-- Tabla de 100.000 productos, sin índice en "categoria"
EXPLAIN ANALYZE SELECT * FROM productos WHERE categoria = 'Electrónica';

-- Resultado:
Seq Scan on productos  (cost=0.00..2020.00 rows=33333 width=80)
                       (actual time=0.035..18.577 rows=33333 loops=1)
  Filter: (categoria = 'Electrónica')
  Rows Removed by Filter: 66667
Planning Time: 0.087 ms
Execution Time: 18.730 ms

Qué está pasando: PostgreSQL leyó las 100.000 filas de la tabla de forma secuencial y descartó 66.667 que no cumplían el filtro. Si Rows Removed by Filter es alto, es señal de que un índice podría ayudar mucho.

Ejemplo 2: Bitmap Index Scan (con índice)

CREATE INDEX idx_productos_categoria ON productos (categoria);

EXPLAIN ANALYZE SELECT * FROM productos WHERE categoria = 'Electrónica';

-- Resultado:
Bitmap Heap Scan on productos  (cost=5.07..1046.07 rows=33333 width=80)
                                (actual time=0.076..3.456 rows=33333 loops=1)
  Recheck Cond: (categoria = 'Electrónica')
  ->  Bitmap Index Scan on idx_productos_categoria  (cost=0.00..5.07 rows=33333 width=0)
                                                     (actual time=0.040..0.040 rows=33333 loops=1)
        Index Cond: (categoria = 'Electrónica')
Planning Time: 0.112 ms
Execution Time: 3.610 ms

Resultado: De 18.730ms a 3.610ms — 5x más rápido solo con agregar un índice. El Bitmap Index Scan primero encuentra las posiciones de las filas en el índice y luego el Bitmap Heap Scan las recupera de la tabla.

Ejemplo 3: Consulta con paralelismo

Para tablas grandes, PostgreSQL puede lanzar workers en paralelo:

EXPLAIN ANALYZE SELECT * FROM tabla_grande;

Gather  (cost=10.00..45524.73 rows=949636 width=97) (actual time=0.673..173.017 rows=955000 loops=1)
  Workers Planned: 4
  Workers Launched: 4
  ->  Parallel Seq Scan on tabla_grande  (cost=0.00..44565.09 rows=237409 width=97)
                                          (actual time=0.039..51.941 rows=191000 loops=5)
Planning Time: 0.093 ms
Execution Time: 209.745 ms

El nodo Gather es el «manager»: espera que los 4 workers terminen y combina los resultados. Fijate que Workers Planned: 4 == Workers Launched: 4 — si fueran distintos, el sistema estaba bajo presión de recursos.

Overhead del Gather = Execution Time − (actual time del Seq Scan) = 209ms − 173ms ≈ 37ms. Ese es el «impuesto» de la paralelización.

Tipos de nodos más comunes

NodoQué hace
Seq ScanLee toda la tabla secuencialmente
Index ScanUsa un índice B-tree para acceder directamente a las filas
Bitmap Heap ScanLee filas usando un bitmap generado por un índice
Nested LoopPara cada fila del outer, busca en el inner. Bueno para sets pequeños
Hash JoinConstruye un hash de una tabla y lo usa para hacer join. Bueno para sets grandes
Merge JoinJoin sobre dos tablas ya ordenadas
SortOrdena filas. Costo de startup alto
AggregateEjecuta funciones como SUM, COUNT, etc.
GatherRecolector de resultados paralelos

Checklist: qué buscar en cada plan

  • Estimated rows vs Actual rows — Si difieren por más de 10x, corré ANALYZE para actualizar estadísticas.
  • Rows Removed by Filter alto — Señal de que falta un índice.
  • Seq Scan en tabla grande — Analizá si un índice lo convierte en Index Scan.
  • loops > 1 — Multiplicá rows × loops para el total real.
  • Sort / Hash con costo alto — Puede que work_mem sea bajo y esté spilling a disco.
  • Workers Planned ≠ Workers Launched — El sistema estaba bajo presión de recursos.
  • Planning Time > 50ms — Demasiados joins o particiones en la query.
  • Execution Time — El número final que comparás contra tu SLA.

Opciones útiles de EXPLAIN

-- Ver I/O por nodo (cuántos bloques de 8kB lee/escribe)
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

-- Salida en JSON para procesar con herramientas
EXPLAIN (ANALYZE, FORMAT JSON) SELECT ...;

-- Todo junto: stats completas
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;

Herramientas para visualizar planes

  • explain.depesz.com — Pegás el output y te lo colorea e interpreta
  • explain.dalibo.com — Visualización gráfica del árbol
  • pgAdmin — Incluye visor visual de EXPLAIN integrado
  • DataGrip / DBeaver — IDEs con soporte nativo para EXPLAIN visual

Conclusión

Leer EXPLAIN ANALYZE es una habilidad que se construye con práctica. Los conceptos clave son simples: comparar estimaciones vs. realidad, entender el loops, identificar scans costosos y actuar en consecuencia. Una vez que lo dominás, tenés una ventana directa a lo que PostgreSQL está pensando — y eso vale oro para optimizar performance.

¿Tenés algún plan difícil de interpretar? Dejalo en los comentarios y lo analizamos juntos.