# Por qué tarda tu consulta

URL canónica: https://missyera.com/guias/sql-desde-cero/indices-y-plan/
Última actualización: 2026-11-14
Autora: Gera Flores (Miss Yera)

> **Respuesta corta**
>
> Antes de adivinar por qué una consulta va lenta, yo le pido el plan. En SQLite es EXPLAIN QUERY PLAN, en PostgreSQL y MySQL es EXPLAIN, y en SQL Server es 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.

Capítulo 14 de 23 del libro [SQL desde cero](https://missyera.com/guias/sql-desde-cero.md).

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 🔍

Y dime: **¿cuál es la consulta que más te hace esperar?** Tenla a mano mientras lees, porque al final del capítulo vas a poder preguntarle a la base por qué tarda, en vez de suponerlo ⚡

## Preguntarle a la base qué va a hacer

```
EXPLAIN QUERY PLAN
SELECT id, fecha, monto FROM pedidos WHERE canal = 'WhatsApp' AND monto > 1200;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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';
```

```text
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;
```

```text
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.

![Cómo leer el plan: SEARCH significa que va directo y está usando el índice, SCAN significa que se lee la tabla entera, y las causas más comunes son una función sobre la columna, un LIKE que empieza por porcentaje y comparar contra otro tipo de dato.](https://missyera.com/static/diagramas/webp/sql-09-indice.webp)

*Una sola palabra del plan te dice si el índice sirve. Y las tres causas de abajo tienen algo en común: en las tres el índice existe y está bien hecho, solo que la consulta no lo deja usarlo.*

## 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';
```

```text
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';
```

```text
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%';
```

```text
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*';
```

```text
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';
```

```text
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

| PostgreSQL | `CREATE INDEX i ON clientes (UPPER(nombre))` |
|---|---|
| MySQL | `desde la 8.0.13, y con paréntesis dobles: ((UPPER(nombre)))` |
| SQL Server | `no directamente: se crea una columna calculada persistida y se indexa esa` |
| SQLite | `CREATE 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';
```

```text
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';
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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

| PostgreSQL | `EXPLAIN, y EXPLAIN ANALYZE que además la ejecuta y mide de verdad` |
|---|---|
| MySQL | `EXPLAIN, y EXPLAIN ANALYZE desde la 8.0.18` |
| SQL Server | `SET SHOWPLAN_ALL ON, o el botón de plan de ejecución en SSMS` |
| SQLite | `EXPLAIN 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

| PostgreSQL | `Seq Scan` |
|---|---|
| MySQL | `type: ALL` |
| SQL Server | `Table Scan o Clustered Index Scan` |
| SQLite | `SCAN` |

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

| PostgreSQL | `ANALYZE -- y el autovacuum lo hace solo` |
|---|---|
| MySQL | `ANALYZE TABLE pedidos` |
| SQL Server | `UPDATE STATISTICS pedidos -- también automático` |
| SQLite | `ANALYZE` |

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

| PostgreSQL | `CREATE INDEX i ON pedidos (fecha) WHERE monto > 1000` |
|---|---|
| MySQL | `no los tiene` |
| SQL Server | `CREATE INDEX i ON pedidos (fecha) WHERE monto > 1000 -- filtered index` |
| SQLite | `CREATE 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';
```

```text
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';
```

```text
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';
```

```text
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';
```

```text
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;
```

```text
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';
```

```text
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;
```

```text
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;
```

```text
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 🌟

## El índice que creaste y nadie usa

Y con el plan ya en la mano, la trampa del capítulo. Es la razón número uno por la que un índice nuevo no cambia nada 🐌

La trampa

Creas el índice sobre la fecha porque el reporte mensual tarda. Vuelves a correrlo y tarda igual.

```
CREATE INDEX ix_fecha ON pedidos(fecha);

EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM pedidos
WHERE substr(fecha, 1, 7) = '2025-01';
-- SCAN pedidos USING COVERING INDEX ix_fecha
```

**Qué está mal**

Dice `SCAN`, no `SEARCH` 🐌 Y esa palabra es toda la diferencia: SEARCH salta a las filas que le interesan, SCAN se las mira todas.

El índice está sobre `fecha`, no sobre `substr(fecha, 1, 7)`. En cuanto envuelves la columna en una función, el motor ya no puede usar el orden que guardó, porque no sabe qué le va a devolver esa función. Pasa igual con `UPPER(nombre) = 'ALGO'` y con `CAST(id AS TEXT) = '12'`.

La misma pregunta escrita sin tocar la columna sí lo usa: `WHERE fecha >= '2025-01-01' AND fecha < '2025-02-01'` devuelve un SEARCH.

Por eso `EXPLAIN QUERY PLAN` se mira **siempre** después de crear un índice. Crearlo es fácil, y creer que se está usando sin haberlo comprobado es como creerse un modelo sin listón.

Comprueba que lo tienes

Tienes un índice en la columna fecha y la consulta sigue tardando. Filtras con WHERE strftime('%Y', fecha) = '2026'.

- Al envolver la columna en una función, el índice deja de servir
- El índice está mal creado y hay que rehacerlo
- Hacen falta más índices en esa tabla
- La tabla es demasiado grande para que un índice ayude

## 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.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Crear un índice es fácil. Comprobar que se usa son diez segundos, y es lo único que cambia algo.

Y si la consulta va lenta porque estás trayendo más datos de los que el reporte necesita, a veces el arreglo no es un índice: es que ese cálculo debía vivir en el tablero. La [guía de Power BI](https://missyera.com/guias/power-bi-desde-cero/) lo cuenta desde ese lado ⚡

En el capítulo 14 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! 🌸

## Seguir leyendo

- Anterior: [Cuando el dato lo escribe un desconocido](https://missyera.com/guias/sql-desde-cero/inyeccion-sql.md)
- Siguiente: [El almacén, el lago, y por qué se copian los datos a propósito](https://missyera.com/guias/sql-desde-cero/warehouse-lake-y-capas.md)

## Contacto

- Diagnóstico de 30 minutos con Miss Yera: https://calendly.com/soymissyera/reunion-con-yera
- Correo: hola@missyera.com
- Esta página en HTML: https://missyera.com/guias/sql-desde-cero/indices-y-plan/
- Índice de todas las páginas en texto: https://missyera.com/llms-index.txt
