Estrategias de Indexación — Desde B-Trees hasta BRIN
Guía práctica de índices de bases de datos: B-Trees, Hash, GIN, GiST, BRIN e índices parciales. Aprende cuándo usar cada uno y cómo evitar errores comunes de indexación.
Overview
Una query de dashboard tarda 30 segundos. Le añades un índice en la columna de filtro y baja a 5 milisegundos. Ese es el poder de los índices — y también la trampa. Seis meses después, las escrituras son 40% más lentas, el índice está inflado por millones de updates, y el query planner lo ignora de todos modos porque las estadísticas están obsoletas.
Los índices son la herramienta de rendimiento más impactante en una base de datos, pero no son gratis. Cada índice consume almacenamiento, ralentiza las escrituras y puede perjudicar el rendimiento si el query planner decide que un seq scan es más barato. La diferencia entre una tabla bien indexada y una mal indexada suele ser 100x en velocidad de query — y 2x en velocidad de escritura, en la dirección equivocada.
Esta guía cubre los seis tipos de índice que vas a usar en producción: B-Tree, Hash, GIN, GiST, BRIN e índices parciales. Para cada uno, vas a ver cuándo usarlo, cuándo evitarlo y SQL concreto que podés ejecutar para verificar que está funcionando. Para optimización a nivel de query más allá de la indexación, consulta la Guía Completa de Optimización de Consultas SQL.
Cuándo Usar
La indexación es la herramienta correcta cuando tu workload es de lectura intensiva y las consultas filtran, joinean u ordenan por columnas específicas. Los cuatro escenarios de abajo cubren la gran mayoría de los casos donde un índice mejora drásticamente el rendimiento:
- Consultas que filtran en columnas específicas (WHERE, JOIN)
- Ordenar grandes conjuntos de resultados (ORDER BY, GROUP BY)
- Aplicar restricciones de unicidad (UNIQUE, PRIMARY KEY)
- Acelerar búsqueda full-text, consultas geoespaciales y lookups JSONB
Si tu tabla tiene menos de ~10.000 filas, un seq scan suele ser más rápido que un index scan — el planner lo sabe y va a ignorar tu índice. Si tu workload es casi totalmente de escritura (INSERT/UPDATE/DELETE) con pocas lecturas, los índices perjudican más de lo que ayudan. Para un análisis más profundo de optimización a nivel de query, consulta la Guía Completa de Optimización de Consultas SQL.
Índices B-Tree
El tipo de índice por defecto en la mayoría de bases de datos relacionales. Los B-Trees mantienen datos ordenados que permiten búsquedas O(log n), scans de rango y recorrido ordenado. PostgreSQL, MySQL (InnoDB), SQL Server y SQLite usan B-Tree como estructura de índice por defecto.
CREATE INDEX idx_users_email ON users(email);
-- Usa índice: coincidencia exacta
SELECT * FROM users WHERE email = 'alice@example.com';
-- Usa índice: scan de rango
SELECT * FROM users WHERE email BETWEEN 'a' AND 'c';
-- Usa índice: ORDER BY
SELECT * FROM users ORDER BY email LIMIT 10;
Cuándo usar B-Tree: Lookups de igualdad, consultas de rango (BETWEEN, >, <), ORDER BY y GROUP BY en la columna indexada. Esta es tu opción por defecto — empezá acá salvo que tengas una razón específica para no hacerlo.
Cuándo evitar B-Tree: Búsqueda full-text en columnas de texto grande (usá GIN), lookups de igualdad exclusivamente en valores hashable grandes (Hash es más pequeño), o tablas append-only de series temporales muy grandes (BRIN es 99% más pequeño).
Índices B-Tree Compuestos
El orden de columnas importa. Un índice compuesto en (a, b, c) soporta consultas en a, (a, b) y (a, b, c) — pero NO consultas en b sola o (b, c). La columna inicial es el punto de entrada; sin ella, el índice es inútil.
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at);
-- Usa índice: coincide columna inicial
SELECT * FROM orders WHERE customer_id = 42;
-- Usa índice: coincide columnas iniciales
SELECT * FROM orders WHERE customer_id = 42 AND created_at > '2024-01-01';
-- NO usa índice: salta columna inicial
SELECT * FROM orders WHERE created_at > '2024-01-01';
La regla para el orden de columnas: poné primero la columna con mayor selectividad (más valores distintos), salvo que todas las consultas filtren por una columna específica. Si el 90% de tus consultas filtran por customer_id y después ordenan por created_at, el compuesto (customer_id, created_at) es correcto incluso si created_at tiene más valores distintos. Para entender más sobre patrones de query y trade-offs de normalización, consulta la Guía de Normalización de Bases de Datos.
Índices Hash
Optimizados para comparaciones de igualdad únicamente. Más pequeños y rápidos que B-Trees para coincidencias exactas, pero no soportan consultas de rango ni ordenamiento. PostgreSQL soporta índices hash nativamente; el InnoDB de MySQL no ofrece un tipo de índice hash separado (usa adaptive hash indexing internamente sobre el B-Tree).
CREATE INDEX idx_sessions_token ON sessions USING HASH(token);
-- Rápido: igualdad
SELECT * FROM sessions WHERE token = 'abc123';
-- No puede usar índice hash: rango
SELECT * FROM sessions WHERE token > 'abc';
Cuándo usar Hash: Lookups de igualdad exclusivamente en strings largos o UUIDs donde nunca vas a necesitar consultas de rango u ordenamiento. Tokens de sesión, API keys y hash digests son buenos candidatos. El índice es más pequeño que un B-Tree porque guarda solo el hash, no el valor ordenado.
Cuándo evitar Hash: Si hay alguna posibilidad de que necesites consultas de rango, ORDER BY o prefix matching en el futuro. Los índices hash tampoco pueden usarse para consultas LIKE 'prefix%'. Para la mayoría de los workloads, B-Tree es la opción más segura — la diferencia de tamaño rara vez justifica la flexibilidad perdida.
Índices GIN (Generalized Inverted Index)
Diseñados para columnas multi-valor y búsqueda full-text. Un índice GIN mapea cada elemento (item de array, key de JSONB o token) a las filas que lo contienen — la misma estructura de inverted list que usan los motores de búsqueda. PostgreSQL es la base de datos principal con soporte maduro de GIN.
-- Contención de arrays
CREATE INDEX idx_products_tags ON products USING GIN(tags);
SELECT * FROM products WHERE tags @> ARRAY['electronics', 'wireless'];
-- Búsqueda JSONB
CREATE INDEX idx_events_data ON events USING GIN(data jsonb_path_ops);
SELECT * FROM events WHERE data @> '{"status": "error"}';
-- Full-text search (PostgreSQL)
CREATE INDEX idx_articles_search ON articles USING GIN(to_tsvector('english', content));
SELECT * FROM articles WHERE to_tsvector('english', content) @@ to_tsquery('database & indexing');
Cuándo usar GIN: Arrays con @> (contiene), JSONB con @> o ? (existe la key) y búsqueda full-text con @@ (match de tsquery). GIN es el único tipo de índice que hace rápidas estas consultas — un B-Tree no puede indexar un array o un documento JSONB.
Cuándo evitar GIN: Los índices GIN son costosos de mantener. Cada INSERT o UPDATE a una columna indexada con GIN puede ser mucho más lento que un update de B-Tree porque la inverted list debe actualizarse para cada elemento. Si tu tabla es de escritura intensiva, considerá la opción fastupdate=off (queries más rápidas, escrituras más lentas) o tuning de gin_pending_list_limit. Para tablas de lectura intensiva, GIN es excelente.
Índices GiST (Generalized Search Tree)
Un framework para construir índices sobre tipos de datos complejos: geométricos, rangos y consultas de vecinos más cercanos. GiST no es un tipo de índice único — es un template que las extensiones de PostgreSQL (PostGIS, pg_trgm, btree_gist) implementan para operadores específicos.
-- Geoespacial (PostGIS)
CREATE INDEX idx_locations_geom ON locations USING GIST(geom);
SELECT * FROM locations WHERE ST_DWithin(geom, ST_Point(0,0)::geography, 1000);
-- Consultas de rango
CREATE INDEX idx_reservations_period ON reservations USING GIST(period);
SELECT * FROM reservations WHERE period && daterange('2024-01-01', '2024-01-10');
Cuándo usar GiST: Consultas geoespaciales (PostGIS), range types (daterange, tsrange), trigram fuzzy search (pg_trgm para LIKE '%term%') y nearest-neighbor search (operador <->). Si estás usando PostGIS, GiST es obligatorio — ningún otro tipo de índice soporta operadores espaciales.
Cuándo evitar GiST: Lookups escalares estándar (igualdad, rango en números/fechas). B-Tree es más rápido y más pequeño para estos. GiST también tiene mayor costo de mantenimiento que B-Tree en escrituras.
Índices BRIN (Block Range Index)
Índices compactos para tablas muy grandes y naturalmente ordenadas. En lugar de guardar una entrada por fila (como B-Tree), BRIN guarda solo el min y max por bloque de páginas. Una tabla de series temporales de 1.000 millones de filas puede tener un índice BRIN de 5 MB, comparado con 20 GB para un B-Tree.
-- Datos de series temporales: logs, eventos, métricas
CREATE INDEX idx_logs_created ON logs USING BRIN(created_at);
-- Tamaño: ~1% de B-Tree, pero solo útil para datos ordenados
-- Mejor para: miles de millones de filas, series temporales, workloads append-only
Cuándo usar BRIN: Tablas append-only con ordenamiento natural (logs de series temporales, streams de eventos, métricas). Si tus datos se insertan en orden aproximadamente sorted por la columna indexada, BRIN puede podar bloques enteros de un scan a ~1% del costo de almacenamiento de un B-Tree. Este es el tipo de índice que hace queryable una tabla de miles de millones de filas.
Cuándo evitar BRIN: Tablas con patrones de acceso aleatorio o updates frecuentes. Si el rango min/max de un bloque cambia frecuentemente (updates dispersos por la tabla), BRIN pierde su capacidad de pruning y degrada a un full scan. BRIN también es inútil para lookups de igualdad en columnas de alta cardinalidad — te dice “este bloque podría contener tu valor”, no “esta fila contiene tu valor”.
Índices Parciales
Indexa solo un subconjunto de filas, reduciendo tamaño y mejorando rendimiento de escritura. Un índice parcial en WHERE active = true cuando el 90% de las consultas filtran por active = true es más pequeño y más rápido que un índice completo — y saltea el 10% de filas inactivas por completo.
-- Solo indexa usuarios activos (80% de consultas filtran por activo)
CREATE INDEX idx_users_active_email ON users(email) WHERE active = true;
-- Solo indexa órdenes impagas para reportes de aging
CREATE INDEX idx_orders_unpaid ON orders(created_at) WHERE status = 'unpaid';
Cuándo usar Parcial: Consultas que siempre incluyen una condición WHERE específica. Si cada query en orders filtra por status = 'pending', un índice parcial en WHERE status = 'pending' es dramáticamente más pequeño que un índice completo y el planner lo va a usar. Los índices parciales también reducen la amplificación de escritura — los INSERTs de filas que no matchean la condición no tocan el índice.
Cuándo evitar Parcial: Si las consultas a veces se ejecutan sin la condición de filtro, el índice parcial no se va a usar para esas consultas. Necesitarías un segundo índice completo, lo que derrota el propósito.
Índices de Cobertura (Index-Only Scans)
Incluye columnas adicionales para que la base de datos pueda responder consultas sin tocar el heap (el almacenamiento principal de la tabla). Un index-only scan es 2-10x más rápido que un index scan que tiene que ir a buscar la página del heap, porque evita I/O aleatorio.
-- PostgreSQL: INCLUDE agrega columnas a la hoja del índice
CREATE INDEX idx_orders_customer_total ON orders(customer_id) INCLUDE(total, status);
-- La consulta usa solo el índice — sin acceso al heap
SELECT total, status FROM orders WHERE customer_id = 42;
-- MySQL: índice compuesto cubre naturalmente
CREATE INDEX idx_orders_customer_total ON orders(customer_id, total, status);
PostgreSQL vs MySQL: PostgreSQL usa la cláusula INCLUDE para agregar columnas non-key al índice sin hacerlas parte del orden de sort. MySQL (InnoDB) consigue el mismo efecto con un índice compuesto — las entradas del secondary index ya contienen la primary key, así que un compuesto (customer_id, total, status) cubre SELECT total, status FROM orders WHERE customer_id = 42.
Cuándo usar Covering: Cuando una query selecciona pocas columnas y filtra por una. El caso clásico es SELECT count(*) FROM orders WHERE status = 'pending' — un covering index en (status) que no incluye nada extra todavía necesita un heap fetch por fila, pero un partial index en WHERE status = 'pending' con una covering column puede responderlo como index-only scan.
Matriz de Selección de Índices
| Tipo de Índice | Mejor Para | Evitar Cuando |
|---|---|---|
| B-Tree | Igualdad, rango, ordenamiento | Búsqueda de texto de alta cardinalidad |
| Hash | Coincidencia exacta en texto largo | Consultas de rango necesarias |
| GIN | Arrays, JSONB, full-text | Columnas escalares simples |
| GiST | Geoespacial, rangos | Lookups escalares estándar |
| BRIN | Grandes datasets ordenados | Patrones de acceso aleatorio |
| Parcial | Subconjuntos frecuentemente filtrados | Consultas escanean todas las filas |
El flujo de decisión de abajo muestra cómo elegir el tipo de índice correcto según tu patrón de query:
Cuándo NO Indexar
No toda columna necesita un índice. Añadir índices indiscriminadamente es una de las causas más comunes de escrituras lentas y almacenamiento inflado. Estos son los casos donde deberías saltear el índice:
- Tablas pequeñas (< 10.000 filas): Un seq scan de 10.000 filas toma menos de 1 ms en hardware moderno. El query planner lo sabe y va a ignorar tu índice de todos modos, así que el índice desperdicia almacenamiento y ralentiza escrituras sin beneficio.
- Columnas de baja cardinalidad: Una columna
gendercon 3 valores (M/F/otro) o un booleanis_activeno se beneficia de un B-Tree — el índice devuelve ~50% de las filas, y el planner elige correctamente un seq scan. Los bitmap indexes (Oracle) o partial indexes (PostgreSQL) manejan estos mejor. - Tablas de escritura intensiva con pocas lecturas: Cada índice añade amplificación de escritura. Una tabla con 10 índices y 10.000 INSERTs/seg paga 100.000 updates de índice/seg. Si las lecturas son raras, eliminá los índices y aceptá seq scans. Para workloads de escritura intensiva, consulta la Guía de Replicación de Bases de Datos para estrategias de offload de lecturas a réplicas.
- Columnas nunca usadas en WHERE/JOIN/ORDER BY: Si el query planner nunca usó un índice, es peso muerto. Auditá índices no usados trimestralmente con
pg_stat_user_indexes(PostgreSQL) operformance_schema(MySQL). - Columnas frecuentemente actualizadas: Un índice en una columna que cambia con cada UPDATE fuerza a que el índice se reescriba en cada escritura. Considerá si la frecuencia de update justifica el costo de mantenimiento del índice.
Errores Comunes
- Indexar cada columna — ralentiza escrituras drásticamente; los índices tienen costo de mantenimiento
- Orden incorrecto de columnas en compuestos — la columna inicial debe ser la más selectiva
- Indexar columnas de baja cardinalidad — género, flags booleanos; los índices bitmap manejan estos mejor
- Ignorar índices parciales — indexar 100% de filas cuando las consultas siempre filtran
- No actualizar estadísticas — estadísticas obsoletas llevan a malas elecciones de índice por el query planner
Monitoreo de Uso de Índices
-- PostgreSQL: encuentra índices no usados
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE 'pg_toast%'
ORDER BY schemaname, tablename, indexname;
-- MySQL: uso de índices vía performance_schema
SELECT object_schema, object_name, index_name, count_read
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
ORDER BY count_read DESC;
Para configuración de conexión y ejecución de queries contra PostgreSQL o MySQL, consultá la Receta de Conexión a PostgreSQL y la Receta de Conexión a MySQL.
Troubleshooting
- Índice no usado por el query planner: Ejecutá
EXPLAIN ANALYZEy verificá si el planner eligió un seq scan. Causas comunes: estadísticas obsoletas (ejecutáANALYZE table_name), baja cardinalidad (el índice devuelve demasiadas filas), o la query no matchea la columna inicial del índice. En PostgreSQL, revisápg_statspara ver qué sabe el planner sobre la distribución de tus datos. - Index bloat tras muchos UPDATEs/DELETEs: Los índices B-Tree no reclaman espacio de entradas eliminadas automáticamente. Verificá el bloat con
pgstattuple(PostgreSQL) omysql.innodb_metrics(MySQL). Corregilo conREINDEX INDEX index_name(PostgreSQL) oOPTIMIZE TABLE(MySQL). Para tablas grandes, usáREINDEX CONCURRENTLYpara evitar locks. - CREATE INDEX bloquea escrituras en producción: Un
CREATE INDEXregular toma un lockACCESS EXCLUSIVE— ni lecturas ni escrituras durante el build. UsáCREATE INDEX CONCURRENTLY(PostgreSQL) oALTER TABLE ... ADD INDEX ... ALGORITHM=INPLACE(MySQL 8+) para construir el índice sin bloquear escrituras. Toma más tiempo pero no lockea la tabla. - Index scan más lento que seq scan: Esto pasa cuando el índice tiene baja selectividad — devuelve un gran porcentaje de filas, y el I/O aleatorio de ir a buscar cada fila del heap es más lento que un scan secuencial. El planner elige correctamente seq scan. Corregilo haciendo el índice más selectivo (agregá columnas a un compuesto, usá un partial index o cambiá la columna inicial).
- Estadísticas obsoletas tras una carga masiva: Después de
COPYoINSERTde millones de filas, las estadísticas del planner están desactualizadas y puede elegir malos planes. EjecutáANALYZE table_name(PostgreSQL) oANALYZE TABLE(MySQL) después de cargas masivas. Para automatización, configuráautovacuum(PostgreSQL) oinnodb_stats_auto_recalc(MySQL).
Mantenimiento de Índices
Los índices no son fire-and-forget. Necesitan mantenimiento periódico para mantenerse efectivos:
-- PostgreSQL: reconstruir índices inflados sin locking
REINDEX INDEX CONCURRENTLY idx_orders_customer_date;
-- PostgreSQL: actualizar estadísticas después de cargas masivas
ANALYZE orders;
-- PostgreSQL: verificar bloat de índices
SELECT schemaname, tablename, indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan > 0
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 10;
-- MySQL: reconstruir tabla y actualizar estadísticas
OPTIMIZE TABLE orders;
ANALYZE TABLE orders;
Schedule de mantenimiento: Ejecutá ANALYZE después de cualquier carga masiva de > 10% del tamaño de la tabla. Verificá el bloat de índices mensualmente en tablas de alta escritura. REINDEX trimestralmente o cuando el bloat exceda 30%. Para tablas de series temporales, considerá particionar por fecha para poder dropear particiones viejas en lugar de mantener índices en miles de millones de filas.
Temas Avanzados
Escenario: Optimización de Queries en E-commerce
-- Tabla: orders (50M filas, 500K/día)
-- Problema: Queries de dashboard tardan 30+ segundos
-- Query 1: Órdenes por cliente en rango de fecha
-- Antes: seq scan, 12 segundos
EXPLAIN ANALYZE SELECT * FROM orders
WHERE customer_id = 42 AND created_at >= '2026-01-01';
-- Solución: índice compuesto
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at DESC);
-- Después: index scan, 2ms
-- Query 2: Órdenes por estado y fecha (dashboard)
-- Antes: seq scan + sort, 28 segundos
EXPLAIN ANALYZE SELECT * FROM orders
WHERE status = 'pending' AND created_at >= '2026-01-01'
ORDER BY created_at DESC LIMIT 50;
-- Solución: índice parcial compuesto
CREATE INDEX idx_orders_pending_date ON orders(created_at DESC)
WHERE status = 'pending';
-- Solo indexa órdenes pendientes (~5% de total)
-- Después: index scan, 5ms
-- Query 3: Búsqueda full-text en productos
-- Antes: ILIKE, 15 segundos
EXPLAIN ANALYZE SELECT * FROM products
WHERE name ILIKE '%laptop%' OR description ILIKE '%laptop%';
-- Solución: GIN index con tsvector
CREATE INDEX idx_products_fts ON products
USING GIN(to_tsvector('spanish', name || ' ' || description));
SELECT * FROM products
WHERE to_tsvector('spanish', name || ' ' || description)
@@ to_tsquery('spanish', 'laptop');
-- Después: 50ms
-- Query 4: Conteo de órdenes por día (reporte)
-- Antes: seq scan + aggregate, 45 segundos
EXPLAIN ANALYZE SELECT DATE(created_at), count(*)
FROM orders WHERE created_at >= '2026-01-01'
GROUP BY DATE(created_at) ORDER BY 1;
-- Solución: BRIN index (datos ordenados por fecha)
CREATE INDEX idx_orders_created_brin ON orders USING BRIN(created_at);
-- Solo 1% del tamaño de B-Tree
-- Después: 8 segundos (aceptable para reportes)
-- Query 5: JSONB en metadata de órdenes
EXPLAIN ANALYZE SELECT * FROM orders
WHERE metadata @> '{"channel": "mobile"}';
CREATE INDEX idx_orders_metadata ON orders USING GIN(metadata jsonb_path_ops);
-- Después: 15ms
-- Auditoría de índices no usados (trimestral):
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE 'pg_toast%'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Resultado: 3 índices no usados de 2GB total -> DROP
-- Impacto: 15% menos de sobrecarga en escrituras
Lecciones de este escenario:
- El orden de columnas en compuestos importa enormemente —
(customer_id, created_at)sirve ambas consultas, pero(created_at, customer_id)solo serviría la query de rango de fecha. - Los índices parciales reducen tamaño y mejoran escrituras — el índice de órdenes pendientes es 5% de un índice completo y sirve el 80% de las queries de dashboard.
- BRIN es ideal para series temporales con miles de millones de filas — 1% del tamaño de B-Tree, y los datos están naturalmente ordenados por fecha.
- GIN resuelve búsqueda full-text y JSONB — ningún otro tipo de índice puede hacer rápidas estas consultas.
- Auditá índices no usados trimestralmente — 2GB de índices muertos era 15% de sobrecarga de escritura por cero beneficio.
¿Cómo decido entre B-Tree y BRIN?
Usá B-Tree para datos con acceso aleatorio y consultas de igualdad o rango. Usá BRIN para tablas muy grandes (miles de millones de filas) donde los datos están naturalmente ordenados (series temporales, logs append-only). BRIN ocupa ~1% del tamaño de un B-Tree pero solo es útil cuando las consultas filtran por rango del campo ordenado.
Mejores Prácticas
- Empezá con la query, no con el índice. Antes de crear un índice, ejecutá
EXPLAIN ANALYZEen la query lenta. Entendé qué filtro o join está causando el seq scan, después indexá la columna que lo corrige. Crear índices sin medir primero es cómo las tablas terminan con 15 índices, 10 de los cuales no se usan. - Usá
CREATE INDEX CONCURRENTLYen producción. UnCREATE INDEXregular lockea la tabla contra todas las lecturas y escrituras. En una tabla de 50M filas, eso son minutos de downtime.CONCURRENTLYtoma más tiempo pero no bloquea escrituras. - Auditá índices no usados trimestralmente. Ejecutá
pg_stat_user_indexes(PostgreSQL) operformance_schema(MySQL) y buscáidx_scan = 0. Cada índice no usado es sobrecarga de escritura pura con cero beneficio de lectura. Dropealos. - Preferí índices compuestos sobre dos simples. Un compuesto
(a, b)sirve consultas ena,(a, b)yaconORDER BY b. Dos simples enaybsirven consultas enaobindependientemente, pero el planner solo puede usar uno por query. Matcheá el índice a tus patrones de query reales. - Actualizá estadísticas después de cargas masivas.
ANALYZE table_namedespués deCOPYoINSERTgrandes. Las estadísticas obsoletas llevan a malos planes — el planner puede elegir un seq scan cuando un index scan sería 100x más rápido.
Ejemplo Real: La Query Lenta de las 2AM
Hace unos años me pagearon a las 2AM porque el checkout estaba tirando timeout. La tabla orders tenía 80 millones de filas y una query de dashboard — SELECT count(*) FROM orders WHERE status = 'pending' AND created_at >= now() - interval '24 hours' — tardaba 45 segundos. El dashboard andaba bien; el problema era que esa query tenía un lock que bloqueaba las escrituras del checkout.
Ejecuté EXPLAIN ANALYZE y vi un seq scan. Había un B-Tree en created_at, pero el planner lo ignoraba porque status = 'pending' matcheaba 40% de las filas — demasiadas para que un index scan valga la pena. El problema era la combinación de filtros, no ninguna columna sola.
La solución tomó 10 minutos: un índice parcial compuesto en (created_at) WHERE status = 'pending'. Solo 2% de las órdenes estaban pendientes, así que el índice era chico (~50 MB vs 8 GB para un B-Tree completo) y la query bajó a 8ms. El checkout volvió a la normalidad.
Tres lecciones que aprendí a la mala:
- El planner no está roto — está haciendo una elección racional. Un seq scan sobre 40% de las filas ES más rápido que I/O aleatorio para 32 millones de filas. La solución no es forzar el índice; es hacer que el índice valga la pena (más selectivo, parcial o compuesto).
- Los índices parciales están subestimados. La mayoría de los equipos alcanzan un índice completo por defecto. Si tu query siempre incluye un filtro específico, un partial index es más chico, más rápido de construir y más barato de mantener.
- La contención de locks por queries lentas es un riesgo real de producción. Una query de 45 segundos no solo molesta al usuario del dashboard — bloquea escritores y puede tirar funcionalidades no relacionadas que comparten la tabla.
Preguntas frecuentes
¿Cuántos índices es demasiado?
No hay un número fijo, pero cada índice añade amplificación de escritura. Una regla práctica: si tenés más de 5-7 índices en una sola tabla, auditá cuáles se usan realmente. Dropeá los que tengan idx_scan = 0 en pg_stat_user_indexes. Las tablas de escritura intensiva deberían tener menos índices que las de lectura intensiva.
¿Debería indexar foreign keys?
Sí. El lado que referencia (el lado "muchos") de una foreign key debería casi siempre estar indexado. Sin un índice, cada DELETE de la tabla padre triggerea un full scan de la tabla hija para verificar filas referenciadas. PostgreSQL no crea índices en foreign keys automáticamente — tenés que hacerlo manualmente.
¿Los índices ralentizan INSERT?
Sí. Cada índice en una tabla añade amplificación de escritura — un solo INSERT puede actualizar 5-10 entradas de índice. Para cargas masivas de millones de filas, considerá dropear los índices, cargar los datos y recrear los índices después. El rebuild suele ser más rápido que mantener índices durante la carga.
¿Cuándo usar un índice compuesto vs dos índices simples?
Un índice compuesto en (a, b) puede servir consultas en a, (a, b) y a con ORDER BY b. Dos índices simples en a y b pueden servir consultas en a o b independientemente, pero el planner solo puede usar uno por query (puede bitmap-ANDearlos, pero es menos eficiente). Si tus consultas siempre filtran por ambas columnas, usá un compuesto. Si filtran por cualquiera independientemente, usá dos simples.
¿Qué hago si el query planner ignora mi índice?
Ejecutá EXPLAIN ANALYZE y verificá tres cosas: (1) ¿Las estadísticas están frescas? Ejecutá ANALYZE table_name. (2) ¿El índice es lo suficientemente selectivo? Si devuelve > 25% de las filas, el planner elige correctamente un seq scan. (3) ¿La query matchea el índice? Un índice compuesto en (a, b) no se va a usar para WHERE b = 1 sin a. En PostgreSQL, podés testear con SET enable_seqscan = off para forzar el uso del índice y comparar timing.
¿Cómo mido si un índice está ayudando realmente?
Compará la latencia de la query antes y después con EXPLAIN ANALYZE. Revisá pg_stat_user_indexes para el contador idx_scan — si es 0 después de una semana de tráfico de producción, el índice es peso muerto. Monitoreá pg_stat_database para idx_blks_read vs heap_blks_read — un índice saludable debería tener más lecturas de bloques de índice que de heap para las consultas que sirve.
¿Debería usar CREATE INDEX CONCURRENTLY?
Casi siempre en producción. Un CREATE INDEX regular toma un lock ACCESS EXCLUSIVE — ni lecturas ni escrituras durante el build, lo que puede ser minutos en una tabla grande. CREATE INDEX CONCURRENTLY (PostgreSQL) construye el índice sin bloquear escrituras. Toma más tiempo y puede fallar si una transacción larga modifica la tabla durante el build, pero es la única opción segura para producción.
Recursos Relacionados
Normalización de Bases de Datos — 1NF a 5NF Explicado
Guía visual de normalización de bases de datos: aprende 1NF a 5NF con ejemplos prácticos, cuándo aplicar cada forma y cómo balancear normalización con rendimiento.
GuideSQL Joins — Guía Visual con Ejemplos
Guía visual de SQL joins: INNER, LEFT, RIGHT, FULL OUTER, CROSS y SELF joins con ejemplos prácticos, tips de rendimiento y errores comunes.
GuideReplicación de Bases de Datos
Guía práctica de estrategias de replicación de bases de datos: master-slave, multi-master, síncrona vs asíncrona, y cómo manejar failover y resolución de conflictos.
RecipeConectar a PostgreSQL
Cómo conectar a bases de datos PostgreSQL en Python, JavaScript y Java.
RecipeConectar a MySQL
Cómo conectar a bases de datos MySQL en Python, JavaScript y Java.