Módulo 3: Indexing avanzado
Composite indexes: orden de columnas y selectividad
Descripción de la cápsula
La cápsula anterior te enseñó cuándo un índice B-tree de una sola columna se usa o no. Ahora subimos la apuesta: queries con varios filtros simultáneos.
SELECT * FROM orders
WHERE customer_id = 42
AND status = 'pending'
AND created_at >= '2026-01-01';
Aquí tienes tres caminos posibles:
- Crear tres índices separados, uno por columna. PostgreSQL los combina con
BitmapAnd. - Crear un solo composite index sobre
(customer_id, status, created_at). - Crear un composite con orden distinto, por ejemplo
(status, customer_id, created_at).
Las tres opciones funcionan en el sentido de que la query devuelve los resultados correctos. Pero solo una es óptima. Y la diferencia entre la mejor y la peor puede ser 20-50x en latencia.
Esta cápsula te enseña a elegir. Vas a aprender la leftmost prefix rule (la regla más malentendida del indexing), cómo decidir el orden de columnas según selectividad y patrones de query, y a validar siempre con EXPLAIN que el composite que diseñaste es el que el planner usa.
Objetivo concreto: vas a poder mirar un endpoint con 2-4 filtros y proponer el composite index correcto, justificando el orden por análisis de selectividad y patrones de uso.
La leftmost prefix rule: el corazón del composite
Un composite index (a, b, c) es un B-tree donde las claves son tuplas ordenadas primero por a, luego por b dentro de cada a, luego por c dentro de cada (a, b).
Visualízalo como un índice telefónico ordenado por (apellido, nombre, segundo nombre):
García, Ana, María
García, Ana, Sofía
García, Carlos, Luis
García, Pedro, José
López, Ana, María
López, Beatriz, Elena
...
¿Qué consultas son rápidas con ese ordenamiento?
| Consulta | Eficiente | Por qué |
|---|---|---|
| Buscar "García, Carlos, Luis" | ✅ Sí | Bajas directo a esa tupla |
| Buscar "García, Carlos" (todos los nombres con ese par) | ✅ Sí | Bajas a "García, Carlos, *" y lees consecutivos |
| Buscar "García" (todos los apellidos García) | ✅ Sí | Bajas a "García, *, *" y lees consecutivos |
| Buscar "Carlos" (todos los nombres Carlos sin importar apellido) | ❌ NO | "Carlos" está disperso por todo el índice — bajo cada apellido |
| Buscar "Luis" (todos los segundos nombres Luis) | ❌ NO | Mismo problema, peor: hay que escanear todo |
La leftmost prefix rule: un composite (a, b, c) sirve para queries con WHERE a, WHERE a AND b, WHERE a AND b AND c. NO sirve (o sirve mal) para WHERE b, WHERE c, ni WHERE b AND c.
El "leftmost prefix" significa: las columnas más a la izquierda del índice son las que importan. Puedes "saltar" columnas a la derecha, pero no puedes saltar columnas a la izquierda.
Casos sutiles
Caso 1: rango sobre la primera columna detiene el uso del prefix
(a, b) con query WHERE a > 10 AND b = 5:
- El índice busca todas las filas con
a > 10(rango). - Dentro de cada
a, lasbestán ordenadas, pero los rangos deadistintos están separados. - El índice puede acotar por
a > 10, perob = 5se aplica comoFilterdespués de leer del índice. No tan eficiente como siafuera igualdad.
Regla derivada: pon igualdades antes que rangos en el composite. (status, created_at) es mejor que (created_at, status) para WHERE status = 'pending' AND created_at > X.
Caso 2: orden importa para ORDER BY
(a, b) ordena por a y dentro de cada a por b. Si tu query es:
WHERE a = 5 ORDER BY b DESC LIMIT 10
El índice ya tiene las filas con a = 5 ordenadas por b. PostgreSQL puede leer las primeras 10 al revés, sin Sort separado.
Pero si tu query es:
WHERE a = 5 ORDER BY c DESC LIMIT 10
— el índice no ayuda al ORDER BY (no incluye c en el orden). Necesitas (a, c) o un composite (a, b, c) donde b no esté en medio rompiendo el orden.
Composite vs índices separados: cuándo gana cada uno
Tienes la query:
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
Opción A: dos índices separados
CREATE INDEX idx_customer ON orders(customer_id);
CREATE INDEX idx_status ON orders(status);
PostgreSQL puede combinarlos con Bitmap And:
Bitmap Heap Scan on orders
Recheck Cond: ((customer_id = 42) AND (status = 'pending'::text))
-> BitmapAnd
-> Bitmap Index Scan on idx_customer
Index Cond: (customer_id = 42)
-> Bitmap Index Scan on idx_status
Index Cond: (status = 'pending'::text)
Cómo funciona: lee de cada índice un bitmap con las filas que matchean cada condición, hace AND lógico de los bitmaps, va al heap a buscar solo las que pasan ambos.
Ventaja: flexible. Cada índice sirve también para queries con un solo filtro (WHERE customer_id = X o WHERE status = Y).
Desventaja: más operaciones. Lee dos índices, combina, va al heap. Más buffers que un único composite que ya sabe la respuesta.
Opción B: composite (customer_id, status)
CREATE INDEX idx_customer_status ON orders(customer_id, status);
Index Scan using idx_customer_status on orders
Index Cond: ((customer_id = 42) AND (status = 'pending'::text))
Cómo funciona: baja al árbol con customer_id = 42, dentro de ese rango baja a status = 'pending', devuelve filas directamente.
Ventaja: más rápido para esta query exacta. Una sola pasada por el índice.
Desventaja: sirve para WHERE customer_id, sirve para WHERE customer_id AND status, pero NO sirve para WHERE status solo (leftmost prefix rule).
Heurística de decisión
| Situación | Estrategia |
|---|---|
| Query con 2-3 filtros que casi siempre van juntos | Composite |
Query A usa solo customer_id, query B usa solo status, casi nunca juntos | Dos índices separados |
| Query mezclada: a veces uno, a veces otro, a veces juntos | Composite con la columna más usada como prefix |
| Tabla pequeña (< 100k filas) | A veces nada — Seq Scan gana |
| Tablas write-heavy | Cuantos menos índices, mejor |
Regla práctica: si tienes una query con N filtros, parte de un composite con esos N filtros. Si después ves que también necesitas queries con subconjuntos, evalúa: ¿el composite mismo cubre el subconjunto por leftmost prefix? Si sí, listo. Si no, evalúa agregar un índice separado.
Cómo elegir el orden de columnas
Tres criterios, en orden de prioridad:
Criterio 1: igualdad antes que rango
Las columnas con = (igualdad exacta) deben ir antes que las columnas con <, >, BETWEEN, LIKE 'foo%'.
-- Query
WHERE status = 'pending' AND created_at > '2026-01-01'
-- ✅ Bien: igualdad primero
CREATE INDEX ON orders(status, created_at);
-- ❌ Mal: rango primero
CREATE INDEX ON orders(created_at, status);
Razón: el índice acota perfectamente con la igualdad de status, y dentro de ese subconjunto el rango de created_at está consecutivo.
Criterio 2: alta selectividad antes que baja selectividad
Cuando ambas son igualdad, la columna más selectiva (con más cardinalidad relativa al filtro) suele ir primero.
-- Query
WHERE customer_id = 42 AND status = 'pending'
-- customer_id: 50,000 valores distintos en 5M filas → cada valor cubre ~100 filas
-- status: 4 valores distintos, 'pending' cubre 5% → cubre 250,000 filas
-- ✅ Bien: customer_id (más selectivo) primero
CREATE INDEX ON orders(customer_id, status);
Razón: bajar el árbol con customer_id = 42 deja ~100 filas. Dentro de eso, filtrar status = 'pending' es trivial. Al revés, bajar con status = 'pending' deja 250k filas, y dentro de eso filtrar por customer_id aún acota mucho — pero el árbol ya recorrió mucho más.
Excepción: si una columna se usa siempre y la otra a veces, pon la "siempre" primero, aunque sea menos selectiva. El composite seguirá siendo útil para las queries que solo filtran por la primera.
Criterio 3: orden de ORDER BY y GROUP BY
Si tu query termina con ORDER BY columna_X, considera incluir columna_X en el composite en el mismo sentido del orden, después de las columnas de igualdad.
-- Query
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20
-- ✅ Bien: el composite incluye created_at en el orden esperado
CREATE INDEX ON orders(customer_id, created_at DESC);
Resultado: Index Scan que ya devuelve filas en orden DESC. Sin Sort adicional. LIMIT 20 corta inmediatamente.
(En PostgreSQL, el orden por default es ASC. Especifica DESC si tu ORDER BY lo requiere — un B-tree puede leerse en cualquier dirección, pero a veces la dirección importa para combinaciones complejas.)
Ejemplo trabajado: tres queries, tres índices, decisiones explicadas
Vas a montar el dataset, ver tres queries problemáticas, diseñar el composite correcto para cada una, y validar los planes.
Setup
Reutiliza la demo_orders de la cápsula anterior, o créala de cero:
DROP TABLE IF EXISTS demo_orders;
CREATE TABLE demo_orders (
id BIGSERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL,
total NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO demo_orders (customer_id, status, total, created_at)
SELECT
(random() * 50000)::INTEGER + 1,
CASE
WHEN random() < 0.95 THEN 'completed'
WHEN random() < 0.99 THEN 'pending'
ELSE 'cancelled'
END,
(random() * 1000)::NUMERIC(10, 2),
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 500000);
ANALYZE demo_orders;
Query 1: filtros por customer + status
SELECT * FROM demo_orders
WHERE customer_id = 42 AND status = 'pending';
Análisis:
customer_id: alta cardinalidad (50,000 valores).customer_id = 42cubre ~10 filas.status: baja cardinalidad (3 valores).status = 'pending'cubre ~20,000 filas.- Ambos son igualdad. La columna más selectiva en este caso es
customer_id.
Diseño: composite (customer_id, status).
CREATE INDEX idx_customer_status ON demo_orders(customer_id, status);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM demo_orders WHERE customer_id = 42 AND status = 'pending';
Plan esperado:
Index Scan using idx_customer_status on demo_orders
Index Cond: ((customer_id = 42) AND (status = 'pending'::text))
Buffers: shared hit=4
Planning Time: 0.345 ms
Execution Time: 0.123 ms
Una sola operación, 4 buffers, sub-milisegundo. Ideal.
Query 2: filtro por status + rango de fecha
SELECT * FROM demo_orders
WHERE status = 'pending' AND created_at >= '2026-01-01';
Análisis:
status = 'pending'es igualdad. Cubre ~20,000 filas (5%).created_at >= '2026-01-01'es rango. Cubre una fracción del año, digamos ~30%.- Igualdad antes que rango (criterio 1).
Diseño: composite (status, created_at).
CREATE INDEX idx_status_created ON demo_orders(status, created_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM demo_orders
WHERE status = 'pending' AND created_at >= '2026-01-01';
Plan esperado:
Index Scan using idx_status_created on demo_orders
Index Cond: ((status = 'pending'::text) AND (created_at >= '2026-01-01 00:00:00+00'::timestamp with time zone))
Buffers: shared hit=...
El índice aprovecha igualdad de status y dentro de ese subconjunto recorre el rango de fechas consecutivamente.
Comparación con orden incorrecto: si hubieras creado (created_at, status), el plan haría rango de created_at primero (cubre 30%) y luego Filter: status = 'pending'. Más buffers leídos, más filas descartadas.
Query 3: filtro + ordenamiento + límite
SELECT * FROM demo_orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
Análisis:
customer_id = 42es igualdad sobre alta cardinalidad. Devuelve ~10 filas.ORDER BY created_at DESC LIMIT 20necesita las filas ordenadas.
Diseño: composite (customer_id, created_at DESC). La columna del orden va después de la igualdad, en el sentido del orden.
CREATE INDEX idx_customer_created_desc ON demo_orders(customer_id, created_at DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM demo_orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
Plan esperado:
Limit
-> Index Scan using idx_customer_created_desc on demo_orders
Index Cond: (customer_id = 42)
Buffers: shared hit=...
Sin Sort separado. El índice ya está ordenado, lee las primeras 20 y termina.
Sin el índice correcto (digamos solo (customer_id) sin orden): el plan tendría un Sort extra entre el Index Scan y el Limit. Tolerable para 10 filas, problema serio si en otra query el customer_id cubriera 50k filas que hay que ordenar.
¿Por qué importa esto en el trabajo real?
1. Composite mal ordenado = índice que no se usa.
El error más común: agregar columnas al composite en el orden en que aparecen en la query (o alfabético) sin pensar selectividad. El índice existe pero el planner lo ignora porque otro plan es más barato. Tablespace gastado, writes ralentizados, cero beneficio.
2. La conversación con el equipo se vuelve técnica.
En un PR review, "agregué un composite porque la query lo necesitaba" es respuesta junior. "Agregué (customer_id, status) porque customer_id tiene cardinalidad 50k y status cubre 5% para pending; el composite favorece igualdad-antes-de-baja-selectividad y validé con EXPLAIN que ahora hace Index Scan directo en vez de Bitmap And" es respuesta senior.
3. Reduces número total de índices.
Un composite bien diseñado reemplaza dos o tres índices separados. Menos índices = más rápidos los writes, menos disco, menos para mantener en REINDEX.
4. Mejoras ORDER BY sin Sort.
Endpoints con paginación + filtro (WHERE customer_id = X ORDER BY created_at DESC LIMIT 20) son omnipresentes. El composite que respeta el orden elimina el Sort y reduce p99 dramáticamente.
Trampas y errores comunes
Error 1 (conceptual): asumir que un composite (a, b) sirve para WHERE b
Síntoma: "Tengo idx_orders_customer_status y la query WHERE status = 'pending' sigue lenta."
Por qué pasa: la leftmost prefix rule. (customer_id, status) está ordenado primero por customer_id. Para buscar solo por status, PostgreSQL tendría que escanear todo el índice, lo cual generalmente no aporta sobre Seq Scan. (Hay un patrón llamado "skip scan" en otras bases que ayuda a este caso, pero PostgreSQL no lo implementa hasta la fecha.)
Cómo detectar: EXPLAIN muestra Seq Scan o usa otro índice.
Cómo corregir: si necesitas filtrar solo por status con frecuencia, agrega un índice separado o invierte el composite. Pero antes evalúa: ¿status solo es realmente útil indexar? Si cubre 5%, sí (partial index, cápsula 05); si cubre 95%, no.
Error 2 (práctico): poner columnas en el composite en el orden de la query
Síntoma: la query es WHERE status = 'pending' AND customer_id = 42, así que creas (status, customer_id).
Por qué a veces es subóptimo: el orden de columnas en el WHERE no importa para el resultado, pero importa muchísimo para el orden del composite. Lo que importa es selectividad.
Cómo corregir: decide el orden por análisis de selectividad y patrones de uso, no por el orden textual de la query. PostgreSQL aplica WHERE a AND b igual que WHERE b AND a.
Error 3 (conceptual): composites enormes "por las dudas"
Síntoma: CREATE INDEX ON orders(customer_id, status, created_at, total, currency, region); para "cubrir todo".
Por qué es erróneo: el índice se vuelve enorme. Cada columna agregada aumenta su tamaño en disco. Más importante: la leftmost prefix rule significa que las columnas a la derecha solo ayudan si las de la izquierda están en el WHERE. Si nadie filtra por (customer_id, status, created_at, total, currency) pero solo por los primeros tres, las dos últimas son peso muerto.
Cómo corregir: mantén composites de 2-4 columnas máximo. Si necesitas devolver columnas extra sin filtrar por ellas, considera covering indexes con INCLUDE (cápsula 04).
Error 4 (conceptual): ignorar que el rango "rompe" el prefix
Síntoma: creas (created_at, status) para WHERE created_at >= X AND status = 'pending', pensando que el composite cubre ambos.
Por qué es subóptimo: el rango sobre created_at cubre muchas filas. Dentro de cada created_at distinto, las status están ordenadas, pero el planner no puede saltar entre rangos de created_at y aplicar la igualdad de status eficientemente. El status se aplica como Filter post-índice.
Cómo detectar: EXPLAIN muestra Index Cond: (created_at >= ...) y Filter: (status = 'pending') por separado.
Cómo corregir: invierte el orden: (status, created_at). Igualdad de status acota primero, dentro de eso el rango de created_at es eficiente.
Error 5 (práctico): no validar que el composite se usa
Síntoma: creas el composite, asumes que ya está, y meses después descubres que el plan sigue mostrando Bitmap And con índices viejos.
Por qué pasa: el planner puede preferir otra combinación si las estadísticas o costos no encajan. O el composite quedó inválido tras un pg_upgrade.
Cómo corregir: captura EXPLAIN (ANALYZE, BUFFERS) después de crear el índice. Confirma que el plan menciona el composite por nombre. Si no lo usa, investiga (cápsula 02 trampa #2).
Error 6 (conceptual): un composite por cada permutación posible
Síntoma: tres columnas, creas (a, b, c), (a, c, b), (b, a, c), (b, c, a), (c, a, b), (c, b, a). Seis índices.
Por qué es erróneo: estás multiplicando overhead de writes y disco por seis. La leftmost prefix rule ya te da varios prefijos gratis con un solo composite.
Cómo corregir: decide cuál es el orden óptimo para tu patrón principal de queries. Acepta que algunas combinaciones secundarias serán menos óptimas. Si una combinación específica es crítica y no encaja en el composite principal, agrega un índice extra. No seis.
Ejercicios
Ejercicio 1: leftmost prefix rule, predicción
Tienes el composite (country, city, postal_code) sobre una tabla addresses. Para cada query, predice si el composite se usa eficientemente.
WHERE country = 'AR'WHERE country = 'AR' AND city = 'Córdoba'WHERE country = 'AR' AND city = 'Córdoba' AND postal_code = '5000'WHERE city = 'Córdoba'(sin country)WHERE country = 'AR' AND postal_code = '5000'(sin city)WHERE postal_code = '5000'(solo postal)WHERE country LIKE 'A%'
Ver solución
| # | Query | Veredicto | Razón |
|---|---|---|---|
| 1 | country = 'AR' | ✅ Sí | Prefix izquierdo, igualdad |
| 2 | country = 'AR' AND city = 'Córdoba' | ✅ Sí | Prefix (country, city), ambos igualdad |
| 3 | country = 'AR' AND city = 'Córdoba' AND postal_code = '5000' | ✅ Sí | Prefix completo |
| 4 | city = 'Córdoba' | ❌ NO | city no es leftmost; necesita país primero |
| 5 | country = 'AR' AND postal_code = '5000' | ⚠️ Parcial | Solo country se usa del índice; postal_code se aplica como Filter (saltó city en el medio) |
| 6 | postal_code = '5000' | ❌ NO | No es leftmost |
| 7 | country LIKE 'A%' | ✅ Sí | Prefijo conocido sobre primera columna, B-tree puede acotar |
Caso 5 explicación adicional: PostgreSQL no implementa "skip scan", así que saltar la columna intermedia (city) significa que el índice acota solo por country y postal_code se filtra después. Si esto se vuelve común, considera un composite alternativo (country, postal_code, city).
Ejercicio 2: diseñar composite para query de catálogo
Tienes esta query de un endpoint de catálogo de productos:
SELECT id, name, price
FROM products
WHERE category_id = 5
AND in_stock = true
ORDER BY price ASC
LIMIT 24;
Datos:
products: 800,000 filas.category_id: 50 categorías, distribución relativamente uniforme (~16,000 productos por categoría).in_stock: booleano, ~70% en stock.price: float, alta cardinalidad, querys ordenan por él frecuentemente.
Diseña el composite y justifica orden de columnas.
Ver solución
Análisis:
category_id = 5: igualdad, selectividad ~2% (16k de 800k). Acota bastante.in_stock = true: igualdad, selectividad 70%. Cubre mayoría dentro de la categoría.ORDER BY price ASC LIMIT 24: necesitamos las filas ordenadas, devolvemos solo 24.
Diseño propuesto: (category_id, in_stock, price).
Razones:
category_idprimero: igualdad con buena selectividad relativa.in_stocksegundo: igualdad, pero menos selectiva. Va después.pricetercero: para que elORDER BY price ASC LIMIT 24no requieraSortseparado. El índice ya está ordenado porpricedentro de cada(category_id, in_stock).
CREATE INDEX idx_products_cat_stock_price
ON products(category_id, in_stock, price);
Plan esperado:
Limit
-> Index Scan using idx_products_cat_stock_price on products
Index Cond: ((category_id = 5) AND (in_stock = true))
Sin Sort, sin Filter adicional, lee 24 filas y corta.
Alternativa a evaluar: si in_stock = true es siempre lo que se pide (los productos sin stock no se muestran nunca), un partial index con WHERE in_stock = true sería incluso más eficiente. Eso lo verás en cápsula 05.
Ejercicio 3: evaluar composite vs índices separados
Una tabla events tiene tres queries comunes:
- Query A:
WHERE user_id = X(95% de las veces) - Query B:
WHERE event_type = 'login'(poco frecuente, casi nunca) - Query C:
WHERE user_id = X AND event_type = 'login'(ocasional)
¿Cuántos índices crearías y de qué tipo?
Ver solución
Recomendación: un solo composite (user_id, event_type).
Razones:
- Cubre Query A (leftmost prefix con
user_id). - Cubre Query C (composite completo).
- Query B casi nunca se ejecuta, no merece su propio índice (más overhead que beneficio).
Si Query B se vuelve frecuente más adelante, agregas un índice separado en event_type. Pero "por las dudas" no.
Alternativa subóptima: dos índices separados en user_id y event_type:
- Cubre Query A (vía
user_id). - Cubre Query B (vía
event_type). - Cubre Query C combinando bitmaps, pero más caro que el composite.
Si Query A es 95% del tráfico, el composite gana en performance media a costa de un índice "no perfectamente óptimo" para Query B. Trade-off correcto.
Ejercicio 4: detectar índice mal ordenado en plan
Tienes la query:
SELECT * FROM logs
WHERE created_at >= '2026-05-01' AND severity = 'ERROR';
El plan actual:
Bitmap Heap Scan on logs
Recheck Cond: ((created_at >= '2026-05-01'::date) AND (severity = 'ERROR'::text))
-> Bitmap Index Scan on idx_logs_created_severity
Index Cond: ((created_at >= '2026-05-01'::date) AND (severity = 'ERROR'::text))
Buffers: shared hit=15000
Execution Time: 450ms
severity = 'ERROR' cubre el 2% de los logs. created_at >= '2026-05-01' cubre el 30%.
¿Qué problema detectas? ¿Qué cambiarías?
Ver solución
Problema detectado: el composite es (created_at, severity) — rango primero, igualdad después. El criterio "igualdad antes de rango" sugiere lo opuesto.
Aunque Bitmap Index Scan está usando el composite, la combinación es subóptima:
- El rango
created_at >= '2026-05-01'cubre 30% del índice. - Dentro de ese rango, el
severity = 'ERROR'se aplica, pero el índice no puede saltar bloques decreated_atdistintos eficientemente para encontrar solo losERROR.
Cambio propuesto: crear (severity, created_at) y dropear el composite original.
CREATE INDEX idx_logs_severity_created ON logs(severity, created_at);
DROP INDEX idx_logs_created_severity;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM logs
WHERE created_at >= '2026-05-01' AND severity = 'ERROR';
Plan esperado:
Index Scan using idx_logs_severity_created on logs
Index Cond: ((severity = 'ERROR'::text) AND (created_at >= '2026-05-01'::date))
Buffers: shared hit=... ← significativamente menos
Execution Time: ...ms ← mucho más rápido
Razón: severity = 'ERROR' acota a 2% del índice (igualdad eficiente). Dentro de ese 2%, created_at >= ... recorre rango consecutivo.
Bonus: si los logs ERROR son raros (2%) y casi todas las queries los buscan, considera un partial index en cápsula 05.
Ejercicio 5: composite con ORDER BY
Tienes:
SELECT * FROM messages
WHERE channel_id = 42
ORDER BY sent_at DESC
LIMIT 50;
Diseña el composite. Después responde: ¿qué pasaría si la query fuera ORDER BY sent_at ASC con el mismo índice?
Ver solución
Diseño: CREATE INDEX ON messages(channel_id, sent_at DESC);
Plan esperado:
Limit
-> Index Scan using idx_messages_channel_sent on messages
Index Cond: (channel_id = 42)
Sin Sort, lee 50 y corta.
Pregunta sobre ASC con el mismo índice:
PostgreSQL puede leer un B-tree en cualquier dirección. Aunque el índice esté creado con DESC, la query con ORDER BY sent_at ASC también puede usar el mismo índice — simplemente lo recorre al revés.
Plan esperado:
Limit
-> Index Scan Backward using idx_messages_channel_sent on messages
Index Cond: (channel_id = 42)
Index Scan Backward indica que está leyendo el índice en dirección inversa. Mismo costo, mismo resultado.
Excepción: cuando combinas múltiples columnas con direcciones mixtas en el ORDER BY, sí importa. Por ejemplo, ORDER BY channel_id ASC, sent_at DESC requiere que el índice tenga las columnas en esas direcciones; un índice puro ASC en ambas no se puede leer en esa combinación de direcciones simultáneamente.
Ejercicio 6: aplicar a tu propio caso
Toma una query real de tu proyecto (o de la bookstore del módulo 1) que tenga 2-3 filtros y/o ORDER BY. Sigue estos pasos:
- Captura el plan actual con
EXPLAIN (ANALYZE, BUFFERS). - Anota cardinalidad y selectividad estimada de cada columna del WHERE.
- Diseña el composite siguiendo los criterios.
- Crea el índice y captura el plan después.
- Compara
Execution TimeyBuffers.
Ver solución
No hay solución única — depende de tu query. Pero la estructura del análisis debe verse así:
## Diseño de índice: [nombre/descripción del endpoint]
**Query:**
```sql
SELECT ... FROM ... WHERE col_a = X AND col_b > Y ORDER BY col_c LIMIT N;
Análisis de columnas:
col_a: cardinalidad N, valor 'X' cubre Y%, igualdadcol_b: cardinalidad N, rango cubre Y%col_c: usado en ORDER BY ASC
Plan ANTES:
Seq Scan on tabla
Filter: ...
Rows Removed by Filter: ...
Buffers: shared hit=A read=B
Execution Time: X ms
Diseño del índice:
CREATE INDEX idx_... ON tabla(col_a, col_b, col_c);
-- Razones: igualdad-rango-orden, leftmost prefix cubre Q1 y Q2 también.
Plan DESPUÉS:
Index Scan using idx_... on tabla
Index Cond: ...
Buffers: shared hit=...
Execution Time: ... ms
Mejora cuantificada:
- Buffers: A → A' (reducción X%)
- Tiempo: X ms → Y ms (reducción Z%)
Si tu mejora es <2x, considera si el índice valió la pena (recuerda: cuesta writes y disco). Si tu mejora es 10x+, claro ganador.
</details>
---
## Resumen y siguiente paso
En esta cápsula aprendiste:
- Un **composite index** `(a, b, c)` está ordenado primero por `a`, luego por `b` dentro de `a`, etc.
- **Leftmost prefix rule:** sirve para `WHERE a`, `WHERE a AND b`, `WHERE a AND b AND c`. NO sirve eficientemente para `WHERE b` ni `WHERE c` solos.
- **Criterio 1 — igualdad antes que rango:** `(status, created_at)` para `WHERE status = X AND created_at > Y`.
- **Criterio 2 — alta selectividad antes que baja:** la columna que más acota va primero.
- **Criterio 3 — orden de `ORDER BY`:** la columna del orden va al final del composite (igualdades primero, orden después).
- **Composite vs índices separados:** composite gana cuando los filtros van juntos casi siempre. Separados ganan cuando se usan independientes la mayor parte del tiempo.
- **Mantén composites pequeños** (2-4 columnas). Para devolver columnas extra sin filtrar por ellas, usa covering indexes con `INCLUDE` (siguiente cápsula).
Antes de avanzar, deberías poder:
- Aplicar la leftmost prefix rule para predecir qué queries aprovecha un composite.
- Decidir el orden de columnas usando los tres criterios.
- Validar con `EXPLAIN` que el composite que diseñaste es el que el planner está usando.
- Diferenciar cuándo conviene composite vs índices separados.
**Siguiente cápsula — Covering indexes con `INCLUDE`.** Tu composite cubre el `WHERE`, perfecto. Pero PostgreSQL todavía tiene que ir al heap para devolver las columnas que la query proyecta (`SELECT col_x, col_y, ...`). Eso es overhead extra. La cápsula 04 te enseña cómo agregar columnas no-clave al índice con `INCLUDE`, habilitando `Index Only Scan` que ni siquiera toca el heap. Diferencia: pasar de `Index Scan` con 5,000 buffers leídos a `Index Only Scan` con 50.
---
## Recursos
1. [Markus Winand — Use The Index, Luke! — "The Equality Operator"](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys) — explicación canónica de composite indexes con visualizaciones excelentes.
2. [Markus Winand — Use The Index, Luke! — "Slow Indexes Part I"](https://use-the-index-luke.com/sql/anatomy/slow-indexes) — por qué un índice "existente pero no usado" no es índice.
3. [PostgreSQL Documentation — Multicolumn Indexes](https://www.postgresql.org/docs/16/indexes-multicolumn.html) — el capítulo oficial sobre composites en PostgreSQL 16.
4. [PostgreSQL Documentation — Combining Multiple Indexes](https://www.postgresql.org/docs/16/indexes-bitmap-scans.html) — cómo PostgreSQL combina varios índices con `BitmapAnd` / `BitmapOr`.
5. [Hubert "depesz" Lubaczewski — "Picking the right composite index"](https://www.depesz.com/2018/11/12/picking-the-right-composite-index/) — caso real de decisión de orden de columnas con plans antes/después.
6. [Bruce Momjian — "Indexing Mistakes"](https://momjian.us/main/writings/pgsql/index_mistakes.pdf) — antipatterns de indexing del core team, sección entera sobre composites.
7. [Tom Lane on multicolumn index usage](https://www.postgresql.org/message-id/15704.1352746906@sss.pgh.pa.us) — explicación canónica de cuándo PostgreSQL elige composite vs combinación de índices simples.
---
*Módulo 3 — Database Performance & Query Tuning Guide*