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ón | Default | Recomendado | Por qué |
|---|---|---|---|
shared_buffers | 128MB | 25% RAM | Caché principal. Muy chico = lecturas de disco constantes |
effective_cache_size | 4GB | 75% RAM | Le dice al planner cuánto OS cache esperar |
work_mem | 4MB | 64-256MB | Para sorts y hash joins. Muy bajo = sorts en disco |
random_page_cost | 4.0 | 1.1 (SSD) | En SSD, bajalo para que el planner prefiera index scans |
maintenance_work_mem | 64MB | 1-2GB | Para 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:
- Medí primero.
EXPLAIN (ANALYZE, BUFFERS)en cada consulta lenta. No adivinés. - Leé las señales. Seq Scans, Rows Removed, Buffers hit vs read te dicen exactamente qué está mal.
- Indexá estratégicamente. Compuestos con igualdad primero. Cobertura para consultas frecuentes. Parciales para datos sesgados.
- Matá los N+1. JOIN, batch loading, o DataLoader.
- Manejá las conexiones. PgBouncer o pooling de la app. Conexiones reales bajo 50.
- Configurá bien.
shared_buffersal 25% de RAM,random_page_cost = 1.1para SSD. - Monitoreá siempre.
pg_stat_statementspara consultas,pg_stat_user_tablespara salud de tablas.
Empezá con EXPLAIN ANALYZE. Seguí el workflow. Medí todo. Tus consultas te lo van a agradecer.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit