Capítulo 12 de 15 11 secciones 17 min

Índices y el plan, o por qué tarda

Preguntarle a la base qué piensa hacer, y las cuatro cosas que dejan un índice sin usar.

Antes de adivinar por qué una consulta es lenta, pídele el plan: en SQLite es EXPLAIN QUERY PLAN, en PostgreSQL y MySQL EXPLAIN, y en SQL Server el plan de ejecución. SCAN significa leer la tabla entera y SEARCH significa ir directo. Un índice compuesto sirve de izquierda a derecha, y lo dejan sin usar cuatro cosas: una función sobre la columna, un LIKE que empieza por %, comparar con otro tipo y un OR entre columnas distintas.

Llega el día. La consulta que corrías todas las mañanas en dos segundos hoy tarda cuarenta, o directamente no vuelve. No cambiaste nada: creció la tabla.

Este capítulo va de por qué pasa eso y de cómo preguntarle a la base qué piensa hacer antes de que lo haga. Porque adivinar por qué una consulta es lenta es perder la tarde, y preguntarle son diez segundos 🔍

Preguntarle a la base qué va a hacer

EXPLAIN QUERY PLAN
SELECT id, fecha, monto FROM pedidos WHERE canal = 'WhatsApp' AND monto > 1200;
id  parent  notused  detail
--  ------  -------  ------------
2   0       0        SCAN pedidos

De esas cuatro columnas la única que importa es detail; las otras tres son numeración interna. Y lo que dice es SCAN pedidos.

SCAN quiere decir que va a leer las 900 filas, una por una, y mirar en cada una si el canal es WhatsApp. Con 900 no lo notas. Con 90 millones, ahí está tu tarde.

Es como buscar a alguien en un edificio tocando todas las puertas. Funciona, y no hace falta que te explique por qué no es la mejor idea 🚪

Un índice

CREATE INDEX idx_pedidos_canal ON pedidos (canal);
EXPLAIN QUERY PLAN
SELECT id, fecha, monto FROM pedidos WHERE canal = 'WhatsApp' AND monto > 1200;
id  parent  notused  detail
--  ------  -------  ------------------------------------------------------
3   0       0        SEARCH pedidos USING INDEX idx_pedidos_canal (canal=?)

SCAN se convirtió en SEARCH. Esa palabra es la que quieres ver.

Un índice es exactamente el índice de un libro: una lista ordenada de valores con la página donde está cada uno. La base ya no toca las 900 puertas, va directo a las 231 de WhatsApp.

Y el nombre no es decoración: idx_tabla_columna se lee solo cuando alguien mire los índices dentro de seis meses.

Cuando el orden también cuesta

EXPLAIN QUERY PLAN
SELECT id FROM pedidos WHERE canal = 'WhatsApp' ORDER BY monto DESC LIMIT 5;
id  parent  notused  detail
--  ------  -------  ------------------------------------------------------
5   0       0        SEARCH pedidos USING INDEX idx_pedidos_canal (canal=?)
21  0       0        USE TEMP B-TREE FOR ORDER BY

Dos líneas ahora. La primera es la buena; la segunda dice USE TEMP B-TREE FOR ORDER BY, y eso significa que la base tuvo que armarse una estructura temporal aparte solo para ordenar. Es trabajo extra que no se ve en el resultado pero sí en el reloj.

CREATE INDEX idx_pedidos_canal_monto ON pedidos (canal, monto);
EXPLAIN QUERY PLAN
SELECT id FROM pedidos WHERE canal = 'WhatsApp' ORDER BY monto DESC LIMIT 5;
id  parent  notused  detail
--  ------  -------  ---------------------------------------------------------------------
4   0       0        SEARCH pedidos USING COVERING INDEX idx_pedidos_canal_monto (canal=?)

Desapareció el temp b-tree. Un índice sobre (canal, monto) ya guarda los montos ordenados dentro de cada canal, así que el ORDER BY sale gratis.

Y fíjate en la palabra nueva: COVERING INDEX. Significa que todo lo que la consulta necesitaba estaba en el índice y ni siquiera hizo falta abrir la tabla. Es lo más rápido que se puede pedir 🏎️

EXPLAIN QUERY PLAN
SELECT canal, monto FROM pedidos WHERE canal = 'Web';
id  parent  notused  detail
--  ------  -------  ---------------------------------------------------------------------
2   0       0        SEARCH pedidos USING COVERING INDEX idx_pedidos_canal_monto (canal=?)

Otro covering: pedí canal y monto, y las dos están en el índice.

La regla del orden de las columnas

Un índice sobre (canal, monto) sirve para buscar por canal, y para buscar por canal y monto juntos. Pero mira qué pasa buscando solo por la segunda.

EXPLAIN QUERY PLAN
SELECT id FROM pedidos WHERE monto > 1200;
id  parent  notused  detail
--  ------  -------  ---------------------------------------------------------
2   0       0        SCAN pedidos USING COVERING INDEX idx_pedidos_canal_monto

Volvió el SCAN. Lee el índice entero en vez de la tabla entera, que es un poquito mejor, pero sigue siendo leerlo todo.

Es una guía telefónica ordenada por apellido y después por nombre: buscar "Flores" es instantáneo, buscar "todos los que se llamen Gera" es leer la guía completa. Un índice compuesto sirve desde la primera columna hacia la derecha, nunca al revés, y eso es igual en los cuatro motores.

Los cuatro sitios donde el índice deja de servir

Esto es lo más útil del capítulo. Tienes el índice, lo ves creado, y la consulta sigue haciendo SCAN. Casi siempre es una de estas cuatro.

CREATE INDEX idx_clientes_nombre ON clientes (nombre);
EXPLAIN QUERY PLAN
SELECT id FROM clientes WHERE nombre = 'Market Central 087';
id  parent  notused  detail
--  ------  -------  -------------------------------------------------------------------
2   0       0        SEARCH clientes USING COVERING INDEX idx_clientes_nombre (nombre=?)

Ese funciona: SEARCH. Ahora el mismo con una función encima.

EXPLAIN QUERY PLAN
SELECT id FROM clientes WHERE UPPER(nombre) = 'MARKET CENTRAL 087';
id  parent  notused  detail
--  ------  -------  ------------------------------------------------------
2   0       0        SCAN clientes USING COVERING INDEX idx_clientes_nombre

1. Una función sobre la columna mata el índice. El índice guarda Market Central 087, no MARKET CENTRAL 087, así que no puede ir directo. Volvió a SCAN: lee el índice entero, igual que en el caso de arriba, y eso es leerlo todo.

Y esto duele porque en el capítulo 5 dijimos que UPPER(columna) es la forma de buscar igual en los cuatro motores. Las dos cosas son verdad, y por eso hay una tercera 🌟

EXPLAIN QUERY PLAN
SELECT id FROM clientes WHERE nombre LIKE '%Central%';
id  parent  notused  detail
--  ------  -------  ------------------------------------------------------
2   0       0        SCAN clientes USING COVERING INDEX idx_clientes_nombre

2. Un LIKE que empieza por % mata el índice. Y no hay arreglo posible: buscar "algo que contenga Central" es como buscar en el índice de un libro las palabras que tengan una "c" en el medio. Para eso hacen falta índices de texto completo, que son otro invento.

EXPLAIN QUERY PLAN
SELECT id FROM clientes WHERE nombre GLOB 'Market*';
id  parent  notused  detail
--  ------  -------  --------------------------------------------------------------------------------
2   0       0        SEARCH clientes USING COVERING INDEX idx_clientes_nombre (nombre>? AND nombre<?)

En cambio buscar por el principio sí funciona: SEARCH, y encima te dice cómo lo hizo, nombre>? AND nombre<?. Convirtió el "empieza por Market" en un rango.

Aquí usé GLOB y no LIKE a propósito, y es un detalle precioso: el LIKE de SQLite no distingue mayúsculas (capítulo 5), y por eso tampoco puede usar el índice ni para buscar por el principio. La comodidad se paga. En PostgreSQL, donde LIKE sí distingue, LIKE 'Market%' usa el índice sin problema.

Y ahora la solución al problema del UPPER: si vas a buscar siempre en mayúsculas, indexa las mayúsculas.

CREATE INDEX idx_upper ON clientes (UPPER(nombre));
EXPLAIN QUERY PLAN
SELECT id FROM clientes WHERE UPPER(nombre) = 'MARKET CENTRAL 087';
id  parent  notused  detail
--  ------  -------  ------------------------------------------------
3   0       0        SEARCH clientes USING INDEX idx_upper (<expr>=?)

SEARCH clientes USING INDEX idx_upper. Un índice puede estar hecho sobre una expresión y no solo sobre una columna, y eso resuelve el choque entre "escribo igual para los cuatro motores" y "quiero que sea rápido".

Índice sobre una expresión

PostgreSQLCREATE INDEX i ON clientes (UPPER(nombre))
MySQLdesde la 8.0.13, y con paréntesis dobles: ((UPPER(nombre)))
SQL Serverno directamente: se crea una columna calculada persistida y se indexa esa
SQLiteCREATE INDEX i ON clientes (UPPER(nombre)) -- desde la 3.9

PostgreSQL y SQLite igual otra vez, MySQL con una sintaxis rara que se olvida, y SQL Server obligando a añadir una columna. Es de las diferencias que más cambian cómo diseñas la tabla.

La tercera y la cuarta no se ven bien en SQLite, así que van dichas:

3. Comparar una columna con otro tipo. Si id_cliente es un número y escribes WHERE id_cliente = '58' entre comillas, algunos motores convierten la columna entera y ahí se fue el índice. En MySQL pasa muchísimo.

4. Un OR entre columnas distintas. WHERE canal = 'Web' OR ciudad = 'Lima' no puede usar un solo índice para las dos. A veces se arregla partiéndolo en dos consultas con UNION, que es del capítulo 8.

Los índices y los JOIN

EXPLAIN QUERY PLAN
SELECT c.nombre, p.monto
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
WHERE c.ciudad = 'Lima';
id  parent  notused  detail
--  ------  -------  --------------------------------------------
3   0       0        SCAN p
5   0       0        SEARCH c USING INTEGER PRIMARY KEY (rowid=?)

Lee así: recorre pedidos entera y por cada fila va a buscar su cliente por la clave primaria. O sea 900 búsquedas. Funciona porque clientes.id es PRIMARY KEY y por tanto ya tiene índice sin que nadie lo pida.

CREATE INDEX idx_pedidos_cliente ON pedidos (id_cliente);
EXPLAIN QUERY PLAN
SELECT c.nombre, p.monto
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
WHERE c.ciudad = 'Lima';
id  parent  notused  detail
--  ------  -------  -------------------------------------------------------
4   0       0        SCAN c
8   0       0        SEARCH p USING INDEX idx_pedidos_cliente (id_cliente=?)

Se dio vuelta el plan: ahora recorre clientes (que son 120) y por cada uno busca sus pedidos con el índice nuevo. 120 búsquedas en vez de 900.

Y de aquí sale la regla que más rendimiento regala por menos trabajo: indexa siempre las columnas de clave foránea. La clave primaria la indexa el motor solo; la foránea, no (salvo en MySQL con InnoDB, que es el único de los cuatro que lo hace por su cuenta) 🔑

Lo que un índice no arregla

EXPLAIN QUERY PLAN
SELECT STRFTIME('%Y-%m', fecha) AS mes, COUNT(*) FROM pedidos GROUP BY mes;
id  parent  notused  detail
--  ------  -------  ----------------------------
6   0       0        SCAN pedidos
8   0       0        USE TEMP B-TREE FOR GROUP BY

SCAN y TEMP B-TREE FOR GROUP BY. Y está bien: si tienes que contar todos los pedidos de todos los meses, hay que leerlos todos, con índice o sin él.

Un índice sirve para encontrar pocas filas entre muchas. Si la consulta necesita casi todas, leer la tabla entera es lo más rápido que hay y el motor lo sabe. Por eso a veces creas un índice, el plan no cambia, y el motor tiene razón 🤷‍♀️

Lo que cuesta un índice

SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name;
name                     tbl_name
-----------------------  --------
idx_clientes_nombre      clientes
idx_pedidos_canal        pedidos
idx_pedidos_canal_monto  pedidos
idx_pedidos_cliente      pedidos
idx_upper                clientes

Cinco índices llevamos. Y aquí va el aviso, porque a todo el mundo le pasa: la primera reacción cuando algo va lento es crear índices, y cada índice ocupa espacio y hace más lento cada INSERT, UPDATE y DELETE, porque hay que mantenerlos todos al día.

Una tabla que se escribe mucho y se consulta poco no quiere índices. Una que se consulta mucho y se escribe de noche, sí. Y los que no usa nadie son solo peso muerto.

DROP INDEX idx_pedidos_canal;
SELECT COUNT(*) AS indices FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL;
indices
-------
4

Borré idx_pedidos_canal porque el de (canal, monto) ya hace su trabajo: un índice compuesto sirve también para buscar solo por su primera columna, así que el de una sola sobraba.

Un índice también puede ser UNIQUE, y ahí deja de ser solo velocidad y pasa a ser una promesa, como las del capítulo 10.

CREATE UNIQUE INDEX idx_producto_unico ON productos (nombre);
INSERT INTO productos (id, nombre, categoria, precio) VALUES (41, 'Producto 01', 'Snacks', 10);
IntegrityError: UNIQUE constraint failed: productos.nombre

Y uno mal escrito falla al crearse, que es la mejor forma de fallar.

CREATE INDEX idx_malo ON pedidos (id_no_existe);
OperationalError: no such column: id_no_existe

Las estadísticas, que es de lo que vive el planificador

ANALYZE;
SELECT COUNT(*) AS filas_de_estadisticas FROM sqlite_stat1;
filas_de_estadisticas
---------------------
7

El motor no elige el plan al azar: estima cuántas filas va a devolver cada paso y elige el camino más barato. Esa estimación sale de unas estadísticas que hay que mantener al día.

Si cargaste un millón de filas de golpe y las consultas se pusieron raras, esto es lo primero que hay que probar: el motor sigue creyendo que la tabla tiene cien filas y está eligiendo planes para cien filas.

Ver el plan de una consulta

PostgreSQLEXPLAIN, y EXPLAIN ANALYZE que además la ejecuta y mide de verdad
MySQLEXPLAIN, y EXPLAIN ANALYZE desde la 8.0.18
SQL ServerSET SHOWPLAN_ALL ON, o el botón de plan de ejecución en SSMS
SQLiteEXPLAIN QUERY PLAN

EXPLAIN a secas te dice qué PIENSA hacer; EXPLAIN ANALYZE lo ejecuta y te dice qué hizo y cuánto tardó de verdad. Cuando la estimación y la realidad no se parecen, ahí está el problema, y casi siempre son estadísticas viejas.

Cómo se llama "leer la tabla entera" en cada plan

PostgreSQLSeq Scan
MySQLtype: ALL
SQL ServerTable Scan o Clustered Index Scan
SQLiteSCAN

Cuatro nombres para la misma mala noticia. Si aprendes a reconocer estos cuatro y su contrario (Index Scan, type: ref, Index Seek, SEARCH), ya sabes leer un plan en cualquiera de los cuatro.

Actualizar las estadísticas

PostgreSQLANALYZE -- y el autovacuum lo hace solo
MySQLANALYZE TABLE pedidos
SQL ServerUPDATE STATISTICS pedidos -- también automático
SQLiteANALYZE

Tres se llaman parecido y SQL Server otra vez con lo suyo. En PostgreSQL y SQL Server suele estar automatizado; en MySQL y SQLite, después de una carga grande, vale la pena lanzarlo a mano.

Índices parciales, o sea con WHERE

PostgreSQLCREATE INDEX i ON pedidos (fecha) WHERE monto > 1000
MySQLno los tiene
SQL ServerCREATE INDEX i ON pedidos (fecha) WHERE monto > 1000 -- filtered index
SQLiteCREATE INDEX i ON pedidos (fecha) WHERE monto > 1000

Sirven un montón cuando solo consultas una parte de la tabla: el índice ocupa mucho menos y se mantiene más rápido. MySQL es el único que se queda fuera.

Ejercicios

Siete sobre tu copia de tienda.db. Intenta antes de abrir 💛

1. Antes y después

Mira el plan de una búsqueda por ciudad, crea el índice y míralo otra vez.

EXPLAIN QUERY PLAN SELECT id, nombre FROM clientes WHERE ciudad = 'Cusco';
id  parent  notused  detail
--  ------  -------  -------------
2   0       0        SCAN clientes
CREATE INDEX idx_clientes_ciudad ON clientes (ciudad);
EXPLAIN QUERY PLAN SELECT id, nombre FROM clientes WHERE ciudad = 'Cusco';
id  parent  notused  detail
--  ------  -------  ----------------------------------------------------------
3   0       0        SEARCH clientes USING INDEX idx_clientes_ciudad (ciudad=?)

De SCAN a SEARCH con una línea. Con 120 clientes no vas a notar nada en el reloj, y el plan igual te lo dice: por eso el plan se mira siempre, no solo cuando algo va lento.

2. El índice compuesto y su orden

Crea un índice sobre (ciudad, segmento) y comprueba con qué consultas sirve y con cuáles no.

CREATE INDEX idx_clientes_ciudad_seg ON clientes (ciudad, segmento);
EXPLAIN QUERY PLAN SELECT id FROM clientes WHERE ciudad = 'Lima' AND segmento = 'Bodega';
id  parent  notused  detail
--  ------  -------  --------------------------------------------------------------------------------------
2   0       0        SEARCH clientes USING COVERING INDEX idx_clientes_ciudad_seg (ciudad=? AND segmento=?)
EXPLAIN QUERY PLAN SELECT id FROM clientes WHERE segmento = 'Bodega';
id  parent  notused  detail
--  ------  -------  ----------------------------------------------------------
2   0       0        SCAN clientes USING COVERING INDEX idx_clientes_ciudad_seg

Con las dos columnas, SEARCH. Con la segunda sola, SCAN. Ese es el orden de las columnas otra vez, y es la decisión más importante al crear un índice compuesto: primero la que vas a usar siempre.

3. La clave primaria ya viene con índice

Mira el plan de buscar un pedido por su id.

EXPLAIN QUERY PLAN SELECT id, monto FROM pedidos WHERE id = 25;
id  parent  notused  detail
--  ------  -------  --------------------------------------------------
2   0       0        SEARCH pedidos USING INTEGER PRIMARY KEY (rowid=?)

INTEGER PRIMARY KEY (rowid=?), sin haber creado nada. En SQLite, una columna INTEGER PRIMARY KEY es el identificador interno de la fila, así que buscar por ella es lo más rápido que existe en la base.

En los otros tres motores también hay índice automático en la clave primaria, aunque por dentro funcione distinto.

4. El índice que no se usa

Con el índice de ciudad ya creado, comprueba qué pasa si buscas con una función encima.

EXPLAIN QUERY PLAN SELECT id FROM clientes WHERE LOWER(ciudad) = 'cusco';
id  parent  notused  detail
--  ------  -------  ------------------------------------------------------
2   0       0        SCAN clientes USING COVERING INDEX idx_clientes_ciudad

Está el índice, existe, y dice SCAN y no SEARCH: lo recorre entero en vez de ir directo. Este ejercicio es para que reconozcas el síntoma, porque cuando alguien te diga "pero si ya le puse índice", lo primero que hay que mirar es si hay una función encima de la columna en el WHERE 🕵️‍♀️

5. Indexa la clave foránea

Mira el plan de juntar detalle con productos, crea el índice que falta y compara.

EXPLAIN QUERY PLAN
SELECT d.id, pr.nombre FROM detalle d JOIN productos pr ON pr.id = d.id_producto;
id  parent  notused  detail
--  ------  -------  ---------------------------------------------
3   0       0        SCAN d
5   0       0        SEARCH pr USING INTEGER PRIMARY KEY (rowid=?)
CREATE INDEX idx_detalle_producto ON detalle (id_producto);
EXPLAIN QUERY PLAN
SELECT pr.nombre, SUM(d.cantidad) FROM productos pr JOIN detalle d ON d.id_producto = pr.id
GROUP BY pr.id, pr.nombre;
id  parent  notused  detail
--  ------  -------  ---------------------------------------------------------
8   0       0        SCAN pr USING COVERING INDEX idx_producto_unico
10  0       0        SEARCH d USING INDEX idx_detalle_producto (id_producto=?)

Fíjate en cuál de las dos tablas lleva el SCAN en cada plan. En el primero recorre detalle, que son 2.682 filas. En el segundo, con el índice puesto y preguntando desde productos, el SCAN se mudó a productos, que son 40, y el detalle se busca con el índice.

Recorrer 40 en vez de 2.682 es casi setenta veces menos trabajo. Con tablas de verdad, esa es la diferencia entre un reporte y un café mientras esperas ☕

6. Un índice que promete

Impide con un índice que dos clientes tengan el mismo nombre exacto.

CREATE UNIQUE INDEX idx_cliente_nombre_unico ON clientes (nombre);
INSERT INTO clientes (id, nombre, ciudad, segmento, fecha_alta)
VALUES (121, 'Market Central 087', 'Lima', 'Bodega', '2026-06-24');
IntegrityError: UNIQUE constraint failed: clientes.nombre

Un UNIQUE INDEX hace las dos cosas a la vez: acelera las búsquedas y garantiza que no se repita. Y ojo con una diferencia: el UNIQUE de la definición de la tabla (capítulo 10) crea por dentro exactamente este mismo índice.

7. Escríbelo para los cuatro

Sin ejecutar: el mismo índice y la misma pregunta por el plan, en los cuatro motores.

-- El CREATE INDEX es igual en los cuatro. Esta es de las buenas.
CREATE INDEX idx_pedidos_canal_fecha ON pedidos (canal, fecha);

-- PostgreSQL
EXPLAIN ANALYZE SELECT id FROM pedidos WHERE canal = 'Web' AND fecha >= '2026-01-01';

-- MySQL
EXPLAIN SELECT id FROM pedidos WHERE canal = 'Web' AND fecha >= '2026-01-01';

-- SQL Server
SET SHOWPLAN_ALL ON;
GO
SELECT id FROM pedidos WHERE canal = 'Web' AND fecha >= '2026-01-01';

-- SQLite
EXPLAIN QUERY PLAN SELECT id FROM pedidos WHERE canal = 'Web' AND fecha >= '2026-01-01';

Crear el índice se escribe igual en los cuatro. Preguntar por el plan, no, y además cada uno te contesta con un formato distinto. La buena noticia es que las ideas son las mismas: leer todo contra buscar, índice cubriente, orden temporal y estimación de filas 🌟

Lo que te llevas

  • 🔍 Pregúntale el plan antes de adivinar. En SQLite es EXPLAIN QUERY PLAN y la única columna que importa es detail.
  • 🚪 SCAN es leer todo, SEARCH es ir directo. Los cuatro motores lo llaman distinto y significan lo mismo.
  • 📚 Un índice compuesto sirve de izquierda a derecha: (canal, monto) vale para canal, no para monto solo.
  • 🏎️ COVERING INDEX es cuando todo lo que pediste estaba en el índice y no hizo falta abrir la tabla.
  • 💀 Lo que mata un índice: una función sobre la columna, un LIKE '%algo%', comparar con otro tipo y un OR entre columnas distintas.
  • 🔑 Indexa las claves foráneas. La primaria la indexa el motor solo; la foránea solo MySQL con InnoDB.
  • ⚖️ Cada índice hace más lenta cada escritura. Los que no usa nadie son peso muerto.
  • 📊 Si los planes se pusieron raros después de una carga grande, actualiza las estadísticas.

En el capítulo 13 vienen las vistas, los procedimientos y los disparadores, o sea las cosas que viven guardadas dentro de la base en vez de en tu archivo de consultas.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?