En la lección anterior descubriste que TiendaVerde tiene once índices que nadie creó y le faltan los de sus once claves foráneas. Toca arreglarlo. Aquí aprenderás la sintaxis completa de CREATE INDEX y, sobre todo, las cuatro variantes que separan a quien "crea índices" de quien diseña índices: los compuestos, con la regla del prefijo por la izquierda que decide en qué orden van las columnas; los parciales, que indexan solo las filas que interesan; los de expresión, que cierran el problema de LOWER(email) que arrastras desde el módulo 6; y los que llevan INCLUDE para conseguir un Index Only Scan.
La segunda mitad es igual de importante y suele faltar en los tutoriales: gestionar los índices que ya existen. Listarlos, medir cuánto ocupan, reconstruirlos y detectar los dos problemas más caros de una base de datos madura: los índices que nadie usa y los que son redundantes con otro.
Aviso sobre los objetos de este módulo. Todos los índices que se crean en el módulo 8 son material didáctico. No forman parte del esquema canónico de TiendaVerde definido en 01-06: si recargas
tiendaverde.sql, desaparecen, y los módulos 9 a 12 no los dan por supuestos.
Contenido
CREATE INDEX: la sintaxis completa y cómo nombrarlos- Los índices que le faltan a TiendaVerde
CREATE INDEX CONCURRENTLY- Índices compuestos y la regla del prefijo por la izquierda
- Índices parciales
- Índices sobre expresiones
INCLUDE: índices cubridores a propósito- Orden y nulos dentro del índice
- Gestión: listar, medir, renombrar, reconstruir y borrar
- Índices no usados y redundantes
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
CREATE INDEX: la sintaxis completa y cómo nombrarlos
CREATE INDEX: la sintaxis completa y cómo nombrarlosCREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] nombre
ON tabla [USING metodo]
( columna_o_expresion [ASC | DESC] [NULLS {FIRST | LAST}] [, ...] )
[INCLUDE (columna [, ...])]
[WHERE condicion];| Elemento | Qué hace | Cuándo lo usarás |
|---|---|---|
UNIQUE |
Además de indexar, impide duplicados | Rara vez a mano: prefiere declarar la restricción UNIQUE (05-01) |
CONCURRENTLY |
Construye el índice sin bloquear las escrituras | En producción, siempre. Sección 3 |
IF NOT EXISTS |
No falla si ya existe uno con ese nombre | Scripts que se ejecutan varias veces |
USING metodo |
btree (por omisión), hash, gin, gist, brin, spgist |
Lección 08-03 |
| Lista de columnas | Una o varias, o expresiones | Secciones 4 y 6 |
ASC/DESC, NULLS FIRST/LAST |
Orden dentro del índice | Sección 8 |
INCLUDE (...) |
Columnas guardadas solo para leerlas | Sección 7 |
WHERE condicion |
Índice parcial: solo indexa las filas que cumplen | Sección 5 |
El caso mínimo, y el 70 % de los índices que crearás en tu vida:
Fíjate en lo que no hace falta poner: ni el método (btree es el valor por omisión) ni el tipo de dato ni el tamaño. Y en lo que sí conviene: el nombre. Si lo omites, PostgreSQL genera <tabla>_<columnas>_idx, y acabas con pedidos_cliente_id_fecha_pedido_idx1. La convención más extendida —y la que usará el curso— es idx_<tabla>_<columnas>:
| Índice | Nombre |
|---|---|
pedidos (cliente_id, fecha_pedido) |
idx_pedidos_cliente_fecha |
productos (categoria_id) WHERE activo |
idx_productos_categoria_activos |
clientes (LOWER(email)) |
idx_clientes_email_lower |
Las razones son las mismas de 05-01 con las restricciones: mensajes de error y planes de ejecución legibles, migraciones que pueden referirse al índice por su nombre, y dos entornos que no acaban con nombres intercambiados. Los índices que respaldan una PRIMARY KEY o un UNIQUE son la excepción: no los crees a mano. Declara la restricción y deja que el motor cree el índice; si lo haces al revés, tendrás un índice sin restricción asociada.
- Los índices que le faltan a TiendaVerde
Empecemos por lo que 08-01 dejó identificado: las once claves foráneas sin índice. Estos son los que de verdad merecen la pena en un TiendaVerde de tamaño real:
-- Índices del módulo 8. NO forman parte del esquema canónico.
CREATE INDEX idx_lineas_pedido_pedido_id ON lineas_pedido (pedido_id);
CREATE INDEX idx_lineas_pedido_producto_id ON lineas_pedido (producto_id);
CREATE INDEX idx_pedidos_cliente_id ON pedidos (cliente_id);
CREATE INDEX idx_productos_categoria_id ON productos (categoria_id);
CREATE INDEX idx_productos_proveedor_id ON productos (proveedor_id);
CREATE INDEX idx_resenas_producto_id ON resenas (producto_id);
CREATE INDEX idx_devoluciones_pedido_id ON devoluciones (pedido_id);Los dos primeros son los más rentables de todo el esquema: lineas_pedido es la tabla con más filas, aparece en casi todos los JOIN del curso y su pedido_id tiene ON DELETE CASCADE, así que sin índice cada borrado de pedido recorre la tabla entera.
Y ahora la comprobación que conviene hacer siempre, aunque duela:
SELECT pg_size_pretty(pg_relation_size('lineas_pedido')) AS tabla,
pg_size_pretty(pg_relation_size('idx_lineas_pedido_pedido_id')) AS indice;| tabla | indice |
|---|---|
| 8192 bytes | 16 kB |
El índice ocupa el doble que la tabla. No es un error: las 47 líneas caben en una sola página de 8 kB, mientras que un B-tree necesita como mínimo dos —la página de metadatos y la raíz—. Es la demostración más limpia de que con estos volúmenes el índice no puede aportar nada, y por eso este módulo necesita una tabla de pruebas grande (la construirás en 08-05). Lo que sigue tiene sentido pensando en el TiendaVerde de dentro de cinco años, con millones de líneas.
CREATE INDEX CONCURRENTLY
CREATE INDEX CONCURRENTLYUn CREATE INDEX normal bloquea las escrituras de la tabla mientras se construye. Las lecturas siguen funcionando; los INSERT, UPDATE y DELETE esperan. Con 47 filas eso dura microsegundos; con 50 millones puede durar veinte minutos, y esos veinte minutos son una caída de servicio.
CONCURRENTLY construye el índice en dos pasadas sobre la tabla, sin tomar el bloqueo que impide escribir. A cambio tiene tres pegas: es más lento (dos recorridos completos, más una espera a que terminen las transacciones abiertas); puede dejar un índice INVALID si algo va mal, es decir creado pero inservible —ocupa espacio, se mantiene en cada escritura y el planificador no lo usa—; y no cabe en una transacción, así que BEGIN; CREATE INDEX CONCURRENTLY ...; da error.
Detectar un índice inválido y curarlo —la cura es siempre la misma, borrarlo y rehacerlo—:
SELECT indexrelid::regclass AS indice FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY idx_pedidos_fecha_pedido;
CREATE INDEX CONCURRENTLY idx_pedidos_fecha_pedido ON pedidos (fecha_pedido);La regla, en una línea: en desarrollo,
CREATE INDEX; en producción sobre una tabla con tráfico,CONCURRENTLYsiempre, y comprueba después que el índice es válido.
El mecanismo por el que un CREATE INDEX normal impide escribir es el de los bloqueos, y con él vienen las transacciones y el modelo de concurrencia de PostgreSQL. Todo eso es el módulo 9; aquí basta con saber que existe y que CONCURRENTLY es la forma de esquivarlo. Es la misma familia de precauciones que viste en 05-06 con los ALTER TABLE que reescriben la tabla.
- Índices compuestos y la regla del prefijo por la izquierda
Un índice compuesto indexa varias columnas a la vez. No es lo mismo que dos índices separados: ordena primero por la primera columna y, dentro de cada valor, por la segunda, exactamente como un ORDER BY cliente_id, fecha_pedido.
Piensa en una guía telefónica ordenada por apellido y luego por nombre. Puedes buscar "todos los García" y puedes buscar "García, Ana". Lo que no puedes hacer es buscar "todas las Ana": tendrías que leer la guía entera. Eso es la regla del prefijo por la izquierda: un índice compuesto sirve para las consultas que usan un prefijo de su lista de columnas, empezando por la primera.
| Consulta | (cliente_id, fecha_pedido) |
(fecha_pedido, cliente_id) |
|---|---|---|
WHERE cliente_id = 1 |
✅ Ideal | ❌ No |
WHERE fecha_pedido >= '2025-06-01' |
❌ No | ✅ Ideal |
WHERE cliente_id = 1 AND fecha_pedido >= '2025-06-01' |
✅ Ideal | ⚠️ Parcial: filtra por fecha y descarta por cliente |
WHERE cliente_id = 1 ORDER BY fecha_pedido |
✅ Ideal, sin Sort |
❌ No |
ORDER BY cliente_id, fecha_pedido |
✅ Sin Sort |
❌ No |
ORDER BY fecha_pedido |
❌ No | ✅ Sin Sort |
El caso estrella, sobre los datos del curso:
SELECT pe.id, pe.fecha_pedido, pe.estado
FROM pedidos AS pe
WHERE pe.cliente_id = 2
ORDER BY pe.fecha_pedido DESC;| id | fecha_pedido | estado |
|---|---|---|
| 11 | 2025-09-09 | entregado |
| 2 | 2025-03-12 | entregado |
Con el índice (cliente_id, fecha_pedido), el motor entra en el bloque de cliente_id = 2 y lee las dos filas ya ordenadas, hacia atrás: cero comparaciones y cero ordenación. (Con 20 pedidos hará un Seq Scan, claro; el razonamiento vale para el caso grande.)
En qué orden poner las columnas
Es la decisión más importante de un índice compuesto, y hay una regla práctica que acierta casi siempre:
Primero las columnas que se comparan por igualdad; después, la que se compara por rango; y al final, la que solo aparece en el
ORDER BY.
El motivo es geométrico. Con (cliente_id, fecha_pedido) y el filtro cliente_id = 1 AND fecha_pedido >= '2025-06-01', el motor salta al inicio de "cliente 1, junio de 2025" y lee un tramo contiguo de hojas: encuentra el único pedido de Lucía posterior a esa fecha. Con (fecha_pedido, cliente_id) tendría que leer todos los pedidos desde junio de 2025, de todos los clientes, e ir descartando. Cuanto antes se cierre el rango, menos entradas se tocan. Dos corolarios:
- En cuanto una columna se compara por rango, las siguientes dejan de servir para filtrar (solo para descartar sin ir a la tabla). Por eso el rango va el último de los filtros.
- Un índice sobre
(a, b)hace innecesario un índice sobre(a), pero no sobre(b). Es la base de la caza de redundantes de la sección 10.
Y un límite práctico: rara vez merece la pena pasar de tres columnas. Cada columna adicional engorda las entradas, reduce el fanout (08-01) y sirve para menos consultas.
- Índices parciales
Un índice parcial lleva una cláusula WHERE y solo indexa las filas que la cumplen. Idea sencilla, dos efectos grandes: el índice es más pequeño —cabe mejor en memoria y se recorre antes— y su mantenimiento es más barato, porque las filas excluidas no lo tocan al insertarse.
CREATE INDEX idx_productos_categoria_activos ON productos (categoria_id) WHERE activo;
CREATE INDEX idx_pedidos_pendientes ON pedidos (fecha_pedido) WHERE estado <> 'entregado';La condición del índice debe implicar la de la consulta para que el planificador lo pueda usar:
-- ✅ Usa idx_pedidos_pendientes: el WHERE incluye la condición del índice
SELECT pe.id, pe.fecha_pedido, pe.estado
FROM pedidos AS pe
WHERE pe.estado <> 'entregado' AND pe.fecha_pedido >= '2026-01-01';
-- ⚠️ NO puede usarlo: la consulta no garantiza estado <> 'entregado'
SELECT pe.id FROM pedidos AS pe WHERE pe.fecha_pedido >= '2026-01-01';| id | fecha_pedido | estado |
|---|---|---|
| 17 | 2026-01-13 | enviado |
| 18 | 2026-01-27 | pagado |
| 19 | 2026-02-09 | pagado |
| 20 | 2026-02-21 | pendiente |
Dónde brillan de verdad. En TiendaVerde, estado <> 'entregado' son 6 pedidos de 20 (30 %) y activo son 19 productos de 20 (95 %): el ahorro es nulo o negativo. Pero esas proporciones se desploman en una tienda real: dentro de cinco años, los pedidos no entregados serán unos 200 de 2.000.000 (0,01 %) y los productos activos unos 3.000 de 40.000 (7,5 %). El índice parcial de pedidos pendientes indexaría doscientas filas en lugar de dos millones: cabría entero en memoria y respondería en microsegundos. Es el patrón clásico de las colas de trabajo —"dame lo que falta por procesar"— y uno de los mejores trucos de PostgreSQL. Otro uso muy frecuente es indexar solo lo que no es nulo: CREATE INDEX idx_pedidos_empleado_id ON pedidos (empleado_id) WHERE empleado_id IS NOT NULL; se ahorra la mitad de las entradas, porque diez de los veinte pedidos son web (01-06), y sigue sirviendo para todas las consultas por comercial.
Nota de dialecto: los índices parciales existen en PostgreSQL y SQLite con esta misma sintaxis, y en SQL Server como filtered indexes. MySQL no los tiene en absoluto: es una de las carencias que más se notan al migrar hacia él.
- Índices sobre expresiones
Aquí se cierra el aviso del módulo 6: una función sobre la columna filtrada impide usar el índice. La solución es indexar el resultado de la función.
CREATE INDEX idx_clientes_email_lower ON clientes (LOWER(email));
SELECT c.id, c.nombre, c.apellidos, c.email
FROM clientes AS c
WHERE LOWER(c.email) = '[email protected]';| id | nombre | apellidos | |
|---|---|---|---|
| 1 | Lucía | Martínez Soler | [email protected] |
Y la regla de oro, de donde vienen casi todos los disgustos:
La expresión del índice debe coincidir literalmente con la de la consulta. El planificador compara expresiones, no significados.
| Índice | Consulta | ¿Sirve? |
|---|---|---|
(LOWER(email)) |
WHERE LOWER(email) = '...' |
✅ Sí |
(LOWER(email)) |
WHERE email = '...' |
❌ No |
(LOWER(email)) |
WHERE LOWER(TRIM(email)) = '...' |
❌ No: la expresión es otra |
(EXTRACT(YEAR FROM fecha_pedido)) |
WHERE EXTRACT(YEAR FROM fecha_pedido) = 2025 |
✅ Sí |
(EXTRACT(YEAR FROM fecha_pedido)) |
WHERE fecha_pedido >= '2025-01-01' AND ... |
❌ No |
Ese último par tiene enjundia. Las dos consultas devuelven los mismos 16 pedidos de 2025, pero cada una necesita su índice, y CREATE INDEX idx_pedidos_anio ON pedidos (EXTRACT(YEAR FROM fecha_pedido)); solo sirve para preguntas anuales. La solución preferible no es esa: es reescribir la consulta como un rango sobre fecha_pedido y usar un índice normal, que servirá además para "los pedidos de marzo", "los de la última semana" y para ORDER BY fecha_pedido. Es el criterio de la sargabilidad que desarrolla 08-04.
Dos requisitos técnicos: la función debe ser IMMUTABLE —para los mismos argumentos, siempre el mismo resultado; LOWER(texto) lo es, NOW() no, y EXTRACT(YEAR FROM ...) sobre un TIMESTAMPTZ tampoco, porque depende de la zona horaria de la sesión, aunque sobre un DATE como el de TiendaVerde sí— y se recalcula en cada escritura, así que encarece los INSERT algo más que un índice normal.
Nota de dialecto: Oracle los tiene desde hace décadas (function-based indexes) y SQLite desde la 3.9. SQL Server no los tiene directamente: se emula con una columna calculada persistida más un índice sobre ella. MySQL 8 los soporta con paréntesis dobles:
CREATE INDEX ... ((LOWER(email))).
INCLUDE: índices cubridores a propósito
INCLUDE: índices cubridores a propósitoINCLUDE añade columnas al índice que no forman parte de la clave: no sirven para buscar ni para ordenar, solo se guardan en las hojas para poder leerlas sin ir a la tabla. Es la forma explícita de fabricar el índice cubridor de 08-01 y provocar un Index Only Scan.
CREATE INDEX idx_pedidos_cliente_inc ON pedidos (cliente_id) INCLUDE (fecha_pedido, estado);
-- Las tres columnas viven en el índice: en una tabla grande, ni se toca el heap
SELECT pe.cliente_id, pe.fecha_pedido, pe.estado FROM pedidos AS pe WHERE pe.cliente_id = 1;Columna en la clave: (a, b) |
Columna en INCLUDE: (a) INCLUDE (b) |
|
|---|---|---|
Sirve para filtrar por b |
✅ Sí (si a también está) |
❌ No |
Sirve para ORDER BY b |
✅ Sí | ❌ No |
| Evita ir a la tabla | ✅ Sí | ✅ Sí |
| Tamaño de las entradas | Mayor en todos los niveles | Menor: solo engorda las hojas |
Compatible con UNIQUE |
Cambia el significado del UNIQUE |
✅ No lo cambia |
Esa última fila es el uso más elegante: CREATE UNIQUE INDEX ... ON clientes (email) INCLUDE (nombre, apellidos) sigue garantizando "un email, un cliente" y además resuelve "¿cómo se llama el dueño de este email?" sin tocar la tabla. INCLUDE existe desde PostgreSQL 11, y también en SQL Server y Oracle; MySQL no lo necesita igual, porque su índice primario es agrupado.
- Orden y nulos dentro del índice
Por omisión un índice B-tree se construye ASC NULLS LAST, igual que el ORDER BY del módulo 2. Y aquí conviene ahorrarse trabajo: para un ORDER BY de una sola columna no hace falta declarar nada, porque PostgreSQL puede recorrer cualquier índice hacia atrás. Un índice (fecha_pedido) sirve igual para ORDER BY fecha_pedido que para ORDER BY fecha_pedido DESC.
El orden explícito solo importa cuando el ORDER BY mezcla direcciones, porque entonces ninguna lectura del índice, ni hacia delante ni hacia atrás, produce ese orden:
-- Un índice (estado, fecha_pedido) NO evita el Sort de esta consulta
SELECT pe.id, pe.estado, pe.fecha_pedido FROM pedidos AS pe
ORDER BY pe.estado ASC, pe.fecha_pedido DESC;
-- Este sí lo evita
CREATE INDEX idx_pedidos_estado_fecha_mix ON pedidos (estado ASC, fecha_pedido DESC);Lo mismo con los nulos: si tus informes hacen ORDER BY empleado_id NULLS FIRST sobre los diez pedidos web de TiendaVerde, un índice (empleado_id NULLS FIRST) evita la ordenación; el índice por omisión, no.
- Gestión: listar, medir, renombrar, reconstruir y borrar
En psql, \di lista todos los índices y \d+ pedidos los de una tabla concreta con su definición; desde SQL, la vista pg_indexes de 08-01. Para medir:
SELECT relname AS tabla,
pg_size_pretty(pg_relation_size(relid)) AS datos,
pg_size_pretty(pg_indexes_size(relid)) AS indices,
pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;| tabla | datos | indices | total |
|---|---|---|---|
| pedidos | 8192 bytes | 96 kB | 112 kB |
| productos | 8192 bytes | 48 kB | 64 kB |
| clientes | 8192 bytes | 48 kB | 64 kB |
| lineas_pedido | 8192 bytes | 48 kB | 56 kB |
| resenas | 8192 bytes | 32 kB | 48 kB |
(Una ejecución de ejemplo tras crear los índices de esta lección; el total incluye además las estructuras auxiliares de cada tabla.) La lectura es perfectamente real: los índices ocupan más de diez veces lo que los datos. Con 20 filas por tabla es una anécdota; en una base de datos madura, que los índices pesen más que los datos es una señal de alarma que hay que investigar con la sección 10.
ALTER INDEX idx_pedidos_cliente_id RENAME TO idx_pedidos_cliente;
REINDEX INDEX CONCURRENTLY idx_pedidos_cliente;
DROP INDEX IF EXISTS idx_pedidos_estado_fecha_mix;REINDEX reconstruye el índice desde cero. Se usa en tres situaciones: índice corrompido, índice que ha crecido de más por acumulación de espacio muerto (el bloat de 08-05), o cambio de la configuración regional que afecta al orden del texto. En el día a día no hace falta reindexar por rutina: PostgreSQL mantiene los árboles equilibrados por su cuenta.
Borrar un índice es instantáneo y no toca los datos; un DROP INDEX normal bloquea la tabla un instante y CONCURRENTLY lo evita. Lo que no puedes borrar así es un índice que respalda una restricción:
ERROR: cannot drop index clientes_email_key because constraint clientes_email_key on table clientes requires it HINT: You can drop constraint clientes_email_key on table clientes instead.
La vía correcta es ALTER TABLE clientes DROP CONSTRAINT clientes_email_key;, que se lleva restricción e índice a la vez.
- Índices no usados y redundantes
En una base de datos con años de vida los índices se acumulan: uno para un informe que ya no existe, otro para una consulta que se reescribió, un tercero "por si acaso". Todos siguen cobrando su peaje en cada escritura.
SELECT relname AS tabla, indexrelname AS indice, idx_scan AS veces_usado,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes WHERE schemaname = 'public'
ORDER BY idx_scan, pg_relation_size(indexrelid) DESC;| tabla | indice | veces_usado | tamano |
|---|---|---|---|
| pedidos | idx_pedidos_anio | 0 | 16 kB |
| productos | idx_productos_proveedor_id | 0 | 16 kB |
| pedidos | idx_pedidos_cliente_fecha | 0 | 16 kB |
| clientes | clientes_pkey | 3 | 16 kB |
(Una ejecución de ejemplo: en TiendaVerde salen ceros porque con 20 filas el planificador elige Seq Scan — 08-05.) Un idx_scan = 0 es un candidato a borrar, con cuatro cautelas:
- Los contadores se acumulan desde el último reinicio de estadísticas. Un índice usado solo por el cierre anual parecerá inútil en marzo.
- Un índice que respalda un
UNIQUEo una PK no se borra, aunque no se use para buscar: está garantizando una restricción. - Los índices de claves foráneas pueden marcar 0 y ser imprescindibles: las comprobaciones de integridad referencial no siempre suman al contador.
- En una réplica de solo lectura los contadores son distintos. Revisa ambos.
Y de la regla del prefijo por la izquierda se deduce el otro problema: si existe un índice sobre (a, b), uno sobre (a) es redundante.
| Pareja de índices | ¿Redundante? |
|---|---|
(cliente_id) y (cliente_id, fecha_pedido) |
✅ Sobra el primero |
(fecha_pedido) y (cliente_id, fecha_pedido) |
❌ No: el segundo no sirve para filtrar solo por fecha |
(cliente_id) y (cliente_id) INCLUDE (estado) |
✅ Sobra el primero |
(cliente_id, fecha_pedido) y (fecha_pedido, cliente_id) |
❌ No: sirven para consultas distintas |
El matiz honesto: el índice corto es más pequeño y, si tu consulta más frecuente solo filtra por cliente_id, será marginalmente más rápido. Casi nunca compensa mantener los dos.
Errores Comunes y Consejos
- Crear un
CREATE INDEXnormal en producción. Bloquea las escrituras durante toda la construcción.CONCURRENTLYexiste justo para eso. - No comprobar que un
CONCURRENTLYterminó bien. Un índiceINVALIDes lo peor de los dos mundos: cuesta mantenerlo y no lo usa nadie. - Poner las columnas del índice compuesto en el orden en que aparecen en el
WHERE. El orden lo manda la regla igualdad → rango, no el orden en que escribiste las condiciones. - Crear
(a)y(a, b). El primero sobra. Es el índice redundante más frecuente que existe. - Crear un índice de expresión que no coincide con la consulta.
LOWER(email)no vale paraLOWER(TRIM(email)). Copia y pega la expresión desde la consulta. - Usar un índice de expresión sobre el año en lugar de reescribir la consulta como un rango. El índice del rango sirve para muchas más preguntas.
- Crear a mano el índice de una clave
UNIQUE. Declara la restricción; el índice viene incluido y sigue asociado a ella. - Consejo: crea los índices de las claves foráneas de entrada. Es el conjunto de índices con mejor relación beneficio/riesgo de cualquier esquema PostgreSQL.
- Consejo: revisa
pg_stat_user_indexesuna vez al trimestre. Borrar tres índices muertos puede acelerar las escrituras más que cualquier optimización de consulta. Y nombra los índices desde el primer día: un plan lleno depedidos_cliente_id_fecha_pedido_idx1es un plan que nadie lee.
Ejercicios
Ejercicio 1
La aplicación de TiendaVerde ejecuta estas tres consultas en la pantalla de ficha de cliente:
-- a) Pedidos del cliente, del más reciente al más antiguo
SELECT id, fecha_pedido, estado FROM pedidos WHERE cliente_id = ? ORDER BY fecha_pedido DESC;
-- b) Pedidos del cliente en un rango de fechas
SELECT id, estado FROM pedidos WHERE cliente_id = ? AND fecha_pedido BETWEEN ? AND ?;
-- c) Pedidos pendientes de todos los clientes, por antigüedad
SELECT id, cliente_id FROM pedidos WHERE estado = 'pendiente' ORDER BY fecha_pedido;Diseña el conjunto mínimo de índices que las cubra bien, escribe los CREATE INDEX y justifica el orden de las columnas de cada uno.
Ejercicio 2
Un equipo ha acumulado estos cinco índices sobre lineas_pedido:
CREATE INDEX idx_lp_1 ON lineas_pedido (pedido_id);
CREATE INDEX idx_lp_2 ON lineas_pedido (pedido_id, producto_id);
CREATE INDEX idx_lp_3 ON lineas_pedido (producto_id);
CREATE INDEX idx_lp_4 ON lineas_pedido (producto_id) INCLUDE (cantidad, precio_unitario);
CREATE INDEX idx_lp_5 ON lineas_pedido (cantidad);- ¿Cuáles son redundantes y por qué?
- ¿Cuál es casi seguro inútil, y qué consulta habría que ver para confirmarlo?
- Deja el conjunto en dos índices y escribe los
DROP INDEXcorrespondientes.
Ejercicio 3
Escribe los índices que resuelven estas tres situaciones y explica en una frase por qué eliges esa variante:
- La aplicación busca clientes escribiendo el email sin importar mayúsculas y minúsculas.
- El panel de administración muestra continuamente los pedidos aún no entregados, que en producción son unos 200 de 2 millones.
- Un informe lista
cliente_id,fecha_pedidoygastos_enviode un cliente concreto, y quieres que no toque la tabla.
Soluciones
Solución 1
CREATE INDEX idx_pedidos_cliente_fecha ON pedidos (cliente_id, fecha_pedido);
CREATE INDEX idx_pedidos_pendientes ON pedidos (fecha_pedido) WHERE estado = 'pendiente';El primero cubre a) y b). cliente_id va primero porque se compara por igualdad, y fecha_pedido después porque es el rango y además el criterio de ordenación: el motor lee un tramo contiguo y ya ordenado, sin Sort. Y como ORDER BY fecha_pedido DESC se resuelve recorriendo el índice hacia atrás, no hace falta declarar DESC.
El segundo cubre c). Podría hacerse con un índice normal sobre (estado, fecha_pedido), pero el parcial es mucho mejor: en TiendaVerde hoy indexaría 1 fila de 20, y en producción unas 200 de 2 millones. La columna indexada es fecha_pedido porque estado ya está fijado por la condición del índice: repetirla sería desperdiciar espacio. No hace falta un índice suelto sobre cliente_id: sería redundante con el primero.
Solución 2
1. Redundantes: idx_lp_1 sobra porque idx_lp_2 empieza por pedido_id y cubre todo lo que él resuelve. idx_lp_3 sobra porque idx_lp_4 tiene la misma clave (producto_id) y además incluye dos columnas para evitar el acceso a la tabla.
2. Casi seguro inútil: idx_lp_5, sobre cantidad. Es una columna de baja cardinalidad (valores de 1 a 8 en las 47 líneas) y nadie busca líneas "por cantidad": es un dato que se muestra y se suma, no por el que se filtra. Para confirmarlo, pg_stat_user_indexes y su idx_scan, con las cuatro cautelas de la sección 10.
3. DROP INDEX idx_lp_1; DROP INDEX idx_lp_3; DROP INDEX idx_lp_5;. Quedan idx_lp_2 sobre (pedido_id, producto_id), que sirve para "las líneas de este pedido" y para "este producto dentro de este pedido", e idx_lp_4 sobre (producto_id) INCLUDE (cantidad, precio_unitario), que resuelve "todas las ventas de este producto" con Index Only Scan. Las dos claves foráneas quedan cubiertas, que era el objetivo de partida.
Solución 3
-- 1. Índice de expresión: la consulta filtra por LOWER(email), no por email
CREATE INDEX idx_clientes_email_lower ON clientes (LOWER(email));
-- 2. Índice parcial: indexa 200 filas en lugar de 2.000.000
CREATE INDEX idx_pedidos_no_entregados ON pedidos (fecha_pedido) WHERE estado <> 'entregado';
-- 3. Índice cubridor con INCLUDE: las tres columnas viven en el índice
CREATE INDEX idx_pedidos_cliente_inc ON pedidos (cliente_id) INCLUDE (fecha_pedido, gastos_envio);- De expresión, porque el índice debe contener exactamente lo que compara la consulta. Cuidado con la regla de oro: si la aplicación pasa a usar
ILIKEo a normalizar conTRIM, este índice deja de servir sin que nadie se entere. - Parcial, porque la condición es constante y minoritaria: el índice cabe en memoria y no se toca al insertar los pedidos que ya nacen entregados.
INCLUDEen lugar de(cliente_id, fecha_pedido, gastos_envio), porquegastos_enviono se usa para filtrar ni para ordenar: metiéndola enINCLUDEsolo engordan las hojas y no los niveles superiores, y el árbol conserva su fanout.
Conclusión
Ya sabes construir índices, no solo pedirlos:
CREATE INDEXtiene ocho piezas, y las importantes sonCONCURRENTLY, la lista de columnas,INCLUDEyWHERE. El método por omisión esbtree.CONCURRENTLYes obligatorio en producción: unCREATE INDEXnormal bloquea las escrituras. A cambio es más lento y puede dejar un índiceINVALIDque hay que borrar y rehacer. El modelo de bloqueos que hay detrás es el módulo 9.- Un índice compuesto sirve para los prefijos por la izquierda de su lista de columnas. El orden se decide con una regla: igualdad primero, rango después, ordenación al final. Y
(a, b)hace redundante a(a). - Los parciales indexan solo un subconjunto de filas: minúsculos, rápidos y baratos de mantener. Son la herramienta ideal para las colas de trabajo y para excluir nulos.
- Los de expresión cierran el aviso del módulo 6, con una regla de oro que no admite matices: la expresión del índice debe coincidir literalmente con la de la consulta, y la función debe ser
IMMUTABLE. INCLUDEfabrica índices cubridores sin engordar la clave, y es la única forma de añadir columnas a un índiceUNIQUEsin cambiar lo que garantiza.- Gestionar es tan importante como crear:
\diypg_indexespara listar,pg_relation_sizepara medir,REINDEXpara reconstruir,DROP INDEXpara borrar — ypg_stat_user_indexespara descubrir los que no usa nadie y la regla del prefijo para descubrir los redundantes.
Con esto sabes crear cualquier índice B-tree que necesites. Pero el B-tree no es el único: PostgreSQL ofrece seis métodos de acceso, y hay problemas —LIKE '%texto%', JSONB, rangos de fechas, tablas históricas de miles de millones de filas— que un B-tree no resuelve bien o no resuelve en absoluto. En la lección 08-03, Tipos de índice y cuándo no indexar, verás la tabla comparativa de los seis métodos, cerrarás la promesa de pg_trgm que 04-01 dejó pendiente y —la parte más importante y la que menos se enseña— aprenderás cuándo un índice no sirve o estorba: tablas pequeñas, columnas de baja cardinalidad, filtros poco selectivos, tablas de escritura intensiva, y el antipatrón de indexarlo todo por si acaso.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
