Back

Rendimiento de Consultas en PostgreSQL: La Guía Definitiva de EXPLAIN ANALYZE para Devs que Ya No Aguantan Más

Tu endpoint de API tarda 3 segundos. El dashboard se cuelga cargando. El job en background que "debería tardar un minuto" lleva 45. Abrís pgAdmin, mirás la consulta y pensás: "Esto debería ser rápido. Es solo un SELECT."

Bienvenido al debugging de rendimiento en PostgreSQL. Todo dev backend se choca con este muro en algún momento, y la mayoría pierde horas adivinando: agregando índices random, reescribiendo consultas a ciegas, o peor, culpando al ORM y pasando de largo.

Esta guía te da el enfoque sistemático. Vamos a cubrir cómo leer la salida de EXPLAIN ANALYZE como un pro, qué índices realmente ayudan (y cuáles empeoran las cosas), cómo encontrar y matar consultas N+1, errores de connection pooling que destruyen el throughput en silencio, y el workflow exacto de debugging que funciona en producción.

La consulta es lenta. ¿Y ahora?

Antes de tocar cualquier cosa, necesitás datos. No adivinanzas. Datos.

El primer paso siempre es el mismo: corré EXPLAIN ANALYZE.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT o.id, o.total, c.name, c.email FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at >= '2026-01-01' AND o.status = 'completed' ORDER BY o.created_at DESC LIMIT 50;

El flag ANALYZE ejecuta la consulta de verdad (no lo hagás con operaciones destructivas sin wrappear en una transacción). BUFFERS muestra la actividad de I/O, que es la info más importante para diagnosticar lentitud.

El resultado se ve así:

Limit  (cost=1247.83..1247.96 rows=50 width=86) (actual time=342.118..342.127 rows=50 loops=1)
  Buffers: shared hit=128 read=15234
  ->  Sort  (cost=1247.83..1302.45 rows=21847 width=86) (actual time=342.115..342.120 rows=50 loops=1)
        Sort Key: o.created_at DESC
        Sort Method: top-N heapsort  Memory: 32kB
        ->  Hash Join  (cost=412.50..876.32 rows=21847 width=86) (actual time=45.821..298.456 rows=21847 loops=1)
              Hash Cond: (o.customer_id = c.id)
              ->  Seq Scan on orders o  (cost=0.00..398.75 rows=21847 width=24) (actual time=0.021..187.345 rows=21847 loops=1)
                    Filter: ((created_at >= '2026-01-01') AND (status = 'completed'))
                    Rows Removed by Filter: 478153
                    Buffers: shared hit=0 read=14891
              ->  Hash  (cost=212.50..212.50 rows=10000 width=62) (actual time=45.678..45.678 rows=10000 loops=1)
                    Buffers: shared hit=128 read=343
                    ->  Seq Scan on customers c  (cost=0.00..212.50 rows=10000 width=62) (actual time=0.012..22.345 rows=10000 loops=1)

Parece complejo. Vamos a desglosarlo.

Leyendo EXPLAIN ANALYZE: Las 5 cosas que importan de verdad

1. Actual Time (no Cost)

Ignorá cost. Es la estimación del planner, no la realidad. Lo que te importa es actual time:

actual time=0.021..187.345 rows=21847

El primer número es tiempo de inicio. El segundo (187.345) es el tiempo total en milisegundos. Este Seq Scan consume 187ms, el 55% del tiempo total de la consulta.

2. Rows Removed by Filter

Esta es la prueba irrefutable de que falta un índice:

Filter: ((created_at >= '2026-01-01') AND (status = 'completed'))
Rows Removed by Filter: 478153

PostgreSQL escaneó ~500.000 filas para encontrar ~22.000 resultados. 95.6% de desperdicio. Si Rows Removed by Filter es 10x o más que las filas retornadas, casi seguro necesitás un índice.

3. Buffers: shared hit vs read

Buffers: shared hit=0 read=14891

shared hit = páginas en caché de RAM de PostgreSQL. read = páginas leídas del disco. Si read es muchísimo mayor que hit, el working set no cabe en memoria.

4. Seq Scan en tablas grandes

Un sequential scan en una tabla con 500K filas es casi siempre un problema. Es el killer #1 de rendimiento en PostgreSQL.

5. Estimated vs Actual Rows

Cuando las filas estimadas y reales difieren más de 10x, el planner eligió mal la estrategia. Generalmente es porque las estadísticas están desactualizadas:

ANALYZE orders;

Estrategia de índices que funciona de verdad

La estrategia de índices de la mayoría es: "Consulta lenta → Índice en la columna del WHERE → Rezar." Eso es como ponerle una curita a un caño roto.

Regla 1: Índices compuestos le ganan a los de columna única

-- Mediocre: columna sola CREATE INDEX idx_orders_status ON orders(status); -- Bien: índice compuesto que matchea el WHERE CREATE INDEX idx_orders_status_created ON orders(status, created_at);

El orden de las columnas importa. Poné primero la condición de igualdad (status = 'completed') y después la de rango (created_at >= '2026-01-01').

Regla 2: Índices de cobertura eliminan los lookups a la tabla

CREATE INDEX idx_orders_covering ON orders(status, created_at DESC) INCLUDE (id, total, customer_id);

Ahora PostgreSQL resuelve la consulta usando solo el índice. Sin acceso al heap.

Resultado: 342ms → 0.23ms. 1.487x más rápido. Heap Fetches: 0 es la clave.

Regla 3: Índices parciales para datos sesgados

CREATE INDEX idx_orders_pending ON orders(created_at DESC) WHERE status = 'pending';

El índice es 10x más chico, entra todo en caché.

Regla 4: Cuándo NO indexar

Los índices no son gratis. Cada índice hace más lentos los INSERT, UPDATE y DELETE.

Encontrá índices que no se usan:

SELECT schemaname, tablename, indexname, idx_scan AS times_used, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;

El problema N+1: muerte por mil consultas

El N+1 no es un problema de PostgreSQL. Es un problema de la capa de aplicación que le hace la vida imposible a PostgreSQL.

Así se ve

// N+1 clásico: 1 consulta para orders + N para customers const orders = await db.query('SELECT * FROM orders LIMIT 100'); for (const order of orders) { const customer = await db.query( 'SELECT * FROM customers WHERE id = $1', [order.customer_id] ); order.customer = customer; } // Total: 101 consultas, 340ms de round-trips de red

Solución 1: JOIN

SELECT o.*, c.name, c.email FROM orders o JOIN customers c ON c.id = o.customer_id LIMIT 100; -- 1 consulta, ~2ms

Solución 2: Batch loading

const orders = await db.query('SELECT * FROM orders LIMIT 100'); const customerIds = [...new Set(orders.map(o => o.customer_id))]; const customers = await db.query( 'SELECT * FROM customers WHERE id = ANY($1)', [customerIds] ); const customerMap = new Map(customers.map(c => [c.id, c])); orders.forEach(o => o.customer = customerMap.get(o.customer_id)); // 2 consultas, ~4ms

Solución 3: Patrón DataLoader

import DataLoader from 'dataloader'; const customerLoader = new DataLoader(async (ids: number[]) => { const customers = await db.query( 'SELECT * FROM customers WHERE id = ANY($1)', [ids] ); const map = new Map(customers.map(c => [c.id, c])); return ids.map(id => map.get(id) ?? null); }); const [cust1, cust2, cust3] = await Promise.all([ customerLoader.load(1), customerLoader.load(2), customerLoader.load(3), ]); // 1 consulta: SELECT * FROM customers WHERE id = ANY([1, 2, 3])

Detectar N+1 en producción

const prisma = new PrismaClient({ log: [{ emit: 'event', level: 'query' }], }); let queryCount = 0; prisma.$on('query', () => { queryCount++; }); if (queryCount > 10) { console.warn(`⚠️ N+1 detectado: ${queryCount} consultas en un solo request`); }

Connection Pooling: el killer silencioso del throughput

Optimizaste consultas, pusiste los índices correctos, mataste los N+1. En desarrollo anda perfecto. Desplegás, le llega carga, y todo se pone más lento.

El culpable casi siempre es el connection pooling.

Por qué las conexiones son caras

Cada conexión PostgreSQL crea un proceso (~10MB RAM). Y lo que muchos no saben: pasadas las ~50 conexiones activas, el rendimiento baja por lock contention.

La solución: PgBouncer

; pgbouncer.ini [pgbouncer] pool_mode = transaction default_pool_size = 20 max_client_conn = 1000

Con pool_mode = transaction, una conexión real se asigna solo durante la transacción y después vuelve al pool.

El error más común

import { Pool } from 'pg'; const pool = new Pool({ max: 20, // NO pongas 50 por instancia si tenés 10 instancias idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, });

10 instancias × max: 50 = 500 conexiones reales. PostgreSQL colapsa. Calculá max_connections / cantidad_de_instancias.

Debugging avanzado

pg_stat_statements

CREATE EXTENSION IF NOT EXISTS pg_stat_statements; SELECT round(total_exec_time::numeric, 2) AS total_ms, calls, round(mean_exec_time::numeric, 2) AS avg_ms, round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct, left(query, 80) AS query_preview FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

Una consulta de 2.81ms que se llama 15.000 veces representa el 34.2% del tiempo total de la base. Ahí es donde tenés que optimizar primero.

pg_stat_user_tables: salud de las tablas

SELECT relname AS tabla, n_live_tup AS filas_vivas, n_dead_tup AS filas_muertas, round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE n_live_tup > 1000 ORDER BY n_dead_tup DESC;

Si dead_pct pasa del 20%, el autovacuum no da abasto.

ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01 );

Monitoreo de locks: encontrar consultas bloqueadas

SELECT blocked.pid AS blocked_pid, blocked_activity.query AS blocked_query, blocking.pid AS blocking_pid, blocking_activity.query AS blocking_query, now() - blocked_activity.query_start AS blocked_duration FROM pg_catalog.pg_locks blocked JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked.pid = blocked_activity.pid JOIN pg_catalog.pg_locks blocking ON blocking.locktype = blocked.locktype AND blocking.database IS NOT DISTINCT FROM blocked.database AND blocking.relation IS NOT DISTINCT FROM blocked.relation AND blocking.pid != blocked.pid JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking.pid = blocking_activity.pid WHERE NOT blocked.granted;

Si ves consultas bloqueadas por más de unos segundos, o tenés transacciones largas sosteniendo locks (solución: transacciones cortas) o migraciones de schema corriendo sin CONCURRENTLY (solución: usá CREATE INDEX CONCURRENTLY).

Workflow completo de debugging

┌────────────────────────────────────┐
│  1. EXPLAIN (ANALYZE, BUFFERS)     │
│     Ejecutá la consulta sospechosa │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  2. ¿Seq Scan en tablas > 10K?    │
│     → Agregá un índice apropiado   │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  3. ¿Rows Removed > 10x?          │
│     → Índice compuesto/cobertura   │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  4. ¿Estimated vs Actual > 10x?   │
│     → Corré ANALYZE                │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  5. ¿Buffers read >> hit?          │
│     → Aumentá shared_buffers       │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  6. pg_stat_statements             │
│     → Optimizá por prioridad       │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  7. ¿> 50 conexiones activas?      │
│     → PgBouncer o reducí pools     │
└────────────────────────────────────┘

Errores comunes de configuración

ConfiguraciónDefaultRecomendadoPor qué
shared_buffers128MB25% RAMCaché principal. Muy chico = lecturas de disco constantes
effective_cache_size4GB75% RAMLe dice al planner cuánto OS cache esperar
work_mem4MB64-256MBPara sorts y hash joins. Muy bajo = sorts en disco
random_page_cost4.01.1 (SSD)En SSD, bajalo para que el planner prefiera index scans
maintenance_work_mem64MB1-2GBPara VACUUM y CREATE INDEX

El cambio con más impacto: random_page_cost = 1.1 en SSD. El default 4.0 es de la era de discos mecánicos.

Ejemplo real: de 12 segundos a 15 milisegundos

SELECT date_trunc('day', o.created_at) AS day, COUNT(*) AS order_count, SUM(o.total) AS revenue, COUNT(DISTINCT o.customer_id) AS unique_customers FROM orders o WHERE o.created_at BETWEEN '2025-01-01' AND '2025-12-31' AND o.store_id = 42 GROUP BY date_trunc('day', o.created_at) ORDER BY day;

Problemas: Seq Scan (5M filas), sort en disco, I/O explosivo.

CREATE INDEX idx_orders_store_date ON orders(store_id, created_at) INCLUDE (total, customer_id); SET work_mem = '128MB';

12.847ms → 15ms. 856x más rápido. Índice compuesto + cobertura + work_mem adecuado.

Conclusión

El debugging de rendimiento en PostgreSQL no es magia negra. Es un proceso sistemático:

  1. Medí primero. EXPLAIN (ANALYZE, BUFFERS) en cada consulta lenta. No adivinés.
  2. Leé las señales. Seq Scans, Rows Removed, Buffers hit vs read te dicen exactamente qué está mal.
  3. Indexá estratégicamente. Compuestos con igualdad primero. Cobertura para consultas frecuentes. Parciales para datos sesgados.
  4. Matá los N+1. JOIN, batch loading, o DataLoader.
  5. Manejá las conexiones. PgBouncer o pooling de la app. Conexiones reales bajo 50.
  6. Configurá bien. shared_buffers al 25% de RAM, random_page_cost = 1.1 para SSD.
  7. Monitoreá siempre. pg_stat_statements para consultas, pg_stat_user_tables para salud de tablas.

Empezá con EXPLAIN ANALYZE. Seguí el workflow. Medí todo. Tus consultas te lo van a agradecer.

PostgreSQLdatabaseperformanceSQLbackendoptimizationEXPLAIN-ANALYZEindexingN+1-problem

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit