# Por qué Postgres no usa tu índice: siete causas, con salidas de EXPLAIN que puedes reproducir

Creaste el índice y EXPLAIN sigue mostrando un sequential scan. Siete razones por las que PostgreSQL ignora un índice (funciones, conversiones de tipo, comodines, selectividad, estadísticas desactualizadas, orden de columnas, tablas diminutas), cada una con su reproducción y su solución.

**Ejecutaste `CREATE INDEX`, ejecutaste la consulta y `EXPLAIN` sigue diciendo `Seq Scan`. Antes de culpar al planificador, ten en cuenta que en la mayoría de los casos tiene razón sobre lo que pediste y se equivoca solo sobre lo que querías decir. Este artículo repasa las siete razones que vemos con más frecuencia por las que PostgreSQL ignora un índice: una función sobre la columna, un tipo que no coincide, un comodín al inicio, un filtro que coincide con la mayor parte de la tabla, estadísticas desactualizadas, un índice multicolumna en el orden equivocado y una tabla que simplemente es demasiado pequeña. Cada caso incluye una reproducción en PostgreSQL 17.11, el plan exacto que produjo y la solución. Pega la configuración en `psql` y, con los ajustes por defecto, obtendrás los mismos planes.**

## ¿Cómo sé si Postgres está usando mi índice?

Pon `EXPLAIN` delante de la consulta. Un nodo del plan llamado `Index Scan`, `Index Only Scan` o `Bitmap Index Scan` significa que se está usando un índice. `Seq Scan` (o `Parallel Seq Scan`) significa que el planificador lee la tabla completa. `EXPLAIN` solo planifica la consulta; `EXPLAIN ANALYZE` además la ejecuta y muestra los conteos reales de filas, que necesitarás para el caso de las estadísticas desactualizadas más abajo.

Dos hábitos hacen que los planes sean más fáciles de leer y comparar:

- `EXPLAIN (COSTS OFF)` elimina los números de costo, que cambian con el tamaño de la tabla y la configuración.
- `EXPLAIN (ANALYZE, BUFFERS)` muestra cuántas páginas tocó realmente una consulta, no solo cuántas esperaba el planificador.

## La configuración

Los siete casos usan una misma tabla de 200,000 usuarios. Los datos se generan, así que no hay nada que descargar:

```sql
SET TimeZone = 'UTC';
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE users (
  id         bigint PRIMARY KEY,
  email      text NOT NULL,
  status     text NOT NULL,
  org_id     int  NOT NULL,
  created_at timestamptz NOT NULL
);

INSERT INTO users
SELECT g,
       'User' || g || '@Example.com',
       CASE WHEN g % 100 = 0 THEN 'banned' ELSE 'active' END,
       (g % 500) + 1,
       timestamptz '2026-01-01' + (g || ' seconds')::interval
FROM generate_series(1, 200000) g;

CREATE INDEX users_email_idx ON users (email);
ANALYZE users;
```

Como punto de partida, una coincidencia exacta sobre la columna indexada usa el índice:

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email = 'User42@Example.com';
```

```text
 Index Scan using users_email_idx on users
   Index Cond: (email = 'User42@Example.com'::text)
```

Todo lo que sigue es una forma de modificar un poco esa consulta y perder el índice.

## 1. Envolviste la columna en una función

Un índice sobre `email` almacena los valores de `email`. Una condición sobre `lower(email)` es una pregunta sobre valores distintos, así que el índice no puede responderla:

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com';
```

```text
 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: (lower(email) = 'user42@example.com'::text)
```

El plan es paralelo, pero sigue siendo un sequential scan. La solución es un **índice de expresión**, un índice sobre la misma expresión que usa la consulta:

```sql
CREATE INDEX users_lower_email_idx ON users (lower(email));
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE lower(email) = 'user42@example.com';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: (lower(email) = 'user42@example.com'::text)
   ->  Bitmap Index Scan on users_lower_email_idx
         Index Cond: (lower(email) = 'user42@example.com'::text)
```

La expresión de la consulta tiene que coincidir con la expresión del índice. `lower(email)` y `lower(trim(email))` son expresiones distintas. La misma regla aplica a `coalesce(col, 0)`, a la aritmética como `price * 100` y a las funciones de fecha. Las funciones de fecha tienen una trampa más: en una columna `timestamptz`, `date_trunc('day', created_at)` y `created_at::date` dependen de la zona horaria de la sesión, así que Postgres no te deja indexarlas directamente (`functions in index expression must be marked IMMUTABLE`). En su lugar, indexa una expresión que fije la zona, como `((created_at AT TIME ZONE 'UTC')::date)`, y usa esa misma expresión en la consulta.

Cuando puedas, reescribe la condición. `created_at >= '2026-01-02' AND created_at < '2026-01-03'` puede usar un índice simple sobre `created_at`, mientras que `created_at::date = '2026-01-02'` no puede.

## 2. Los tipos no coinciden

Cuando los dos lados de una comparación tienen tipos distintos, Postgres tiene que convertir uno de ellos. Si convierte la columna, un índice ordinario sobre la columna deja de aplicar. Aquí `id` es un `bigint` y el literal `42.0` es un `numeric`:

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = 42;
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = 42.0;
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id::text = '42';
```

```text
 Index Scan using users_pkey on users
   Index Cond: (id = 42)

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((id)::numeric = 42.0)

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((id)::text = '42'::text)
```

Fíjate en la línea `Filter`: `(id)::numeric` es la columna siendo convertida. Esa conversión es la pista delatora, y es lo que debes buscar siempre que un plan muestre `Seq Scan` sobre una columna indexada.

La solución es hacer que el literal o el parámetro coincida con la columna. En el código de la aplicación, la fuente habitual es un driver que envía un número como `numeric`, o un valor que llega como cadena. Convierte el parámetro en la consulta (`WHERE id = $1::bigint`) o corrige el tipo en el origen. Si la columna misma tiene el tipo equivocado, por ejemplo un identificador almacenado como `text` y comparado con números, cambiar el tipo de la columna es la solución real.

## 3. El patrón empieza con un comodín

Un índice B-tree está ordenado, así que puede encontrar rápidamente todo lo que empieza con `User4`. No puede encontrar todo lo que termina en `1234@Example.com`, porque los valores no están ordenados por su final:

```sql
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email LIKE '%1234@Example.com';
```

```text
 Seq Scan on users
   Filter: (email ~~ '%1234@Example.com'::text)
```

La extensión `pg_trgm` indexa fragmentos de tres caracteres, lo que permite a Postgres usar un índice para patrones con comodín al inicio, para `ILIKE` y para búsquedas de similitud con `%`. `pg_trgm` está entre las extensiones disponibles en Relyt One. El índice es un índice GIN con la clase de operadores `gin_trgm_ops`:

```sql
CREATE INDEX users_email_trgm_idx ON users USING gin (email gin_trgm_ops);
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE email LIKE '%1234@Example.com';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: (email ~~ '%1234@Example.com'::text)
   ->  Bitmap Index Scan on users_email_trgm_idx
         Index Cond: (email ~~ '%1234@Example.com'::text)
```

Los índices de trigramas son más grandes que los B-tree y más lentos de actualizar, y ayudan más cuando la cadena de búsqueda es lo bastante larga como para contener al menos un trigrama completo. Un patrón con menos de tres caracteres les da poco con qué trabajar. Si lo que necesitas realmente es buscar palabras en un texto, usa la búsqueda de texto completo (`tsvector`) en lugar de `LIKE`.

## 4. El filtro coincide con la mayor parte de la tabla

Un índice vale la pena cuando permite a la base de datos saltarse la mayor parte de la tabla. Si una condición coincide con el 99% de las filas, leer toda la tabla en orden es más barato que saltar por el índice 198,000 veces, y el planificador lo sabe:

```sql
CREATE INDEX users_status_idx ON users (status);
ANALYZE users;

EXPLAIN (COSTS OFF) SELECT * FROM users WHERE status = 'active';
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE status = 'banned';
```

```text
 Seq Scan on users
   Filter: (status = 'active'::text)

 Index Scan using users_status_idx on users
   Index Cond: (status = 'banned'::text)
```

Esto es el índice funcionando como fue diseñado: el mismo índice se usa para el 1% de las filas y se descarta para el 99%. No hay nada que arreglar en el planificador. Si la consulta del valor común es importante, la pregunta es si debería devolver tantas filas en primer lugar (agrega un `LIMIT`, un filtro adicional o una agregación).

El valor raro es donde puedes mejorar las cosas. Un **índice parcial** cubre solo las filas que buscas, así que es pequeño y barato de mantener:

```sql
DROP INDEX users_status_idx;
CREATE INDEX users_banned_idx ON users (id) WHERE status = 'banned';
EXPLAIN (COSTS OFF) SELECT id FROM users WHERE status = 'banned';
```

```text
 Index Only Scan using users_banned_idx on users
```

La cláusula `WHERE` de la consulta tiene que implicar la cláusula `WHERE` del índice. Una consulta que pide `status = 'banned'` puede usar este índice. Una consulta por otro estado no puede.

## 5. Las estadísticas están desactualizadas

El planificador no mira tus datos cuando planifica una consulta. Mira las estadísticas que recopila `ANALYZE` y hace sus estimaciones a partir de ellas. Cuando la tabla cambia mucho después del último `ANALYZE`, las estimaciones dejan de coincidir con la realidad y el plan puede equivocarse en cualquier dirección, incluso eligiendo un índice cuando el índice es una mala idea.

Autovacuum normalmente actualiza las estadísticas cuando ha cambiado cerca del 10% de una tabla (`autovacuum_analyze_scale_factor = 0.1`, más una base de 50 filas). Para mostrar el efecto de forma determinista lo desactivamos para una tabla, la analizamos cuando contiene cinco filas y luego cargamos 300,000 de golpe:

```sql
CREATE TABLE events (id bigint, kind text, created_at timestamptz);
ALTER TABLE events SET (autovacuum_enabled = false);
CREATE INDEX events_kind_idx ON events (kind);

INSERT INTO events SELECT g, 'click', now() FROM generate_series(1, 5) g;
ANALYZE events;
INSERT INTO events SELECT g, 'view', now() FROM generate_series(1, 300000) g;

EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events WHERE kind = 'view';
```

```text
 Aggregate  (cost=4.31..4.32 rows=1 width=8) (actual rows=1 loops=1)
   ->  Index Only Scan using events_kind_idx on events  (cost=0.29..4.30 rows=1 width=0) (actual rows=300000 loops=1)
         Index Cond: (kind = 'view'::text)
         Heap Fetches: 300000
```

El planificador esperaba una fila (`rows=1`) y obtuvo 300,000 (`actual rows=300000`). Eligió un índice para una búsqueda que lee toda la tabla, y `Heap Fetches: 300000` significa que además cada fila requirió una visita a la tabla. Después de `ANALYZE`, la misma consulta cambia de plan:

```sql
ANALYZE events;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM events WHERE kind = 'view';
```

```text
 Finalize Aggregate  (cost=5558.22..5558.23 rows=1 width=8) (actual rows=1 loops=1)
   ->  Gather  (cost=5558.11..5558.22 rows=1 width=8) (actual rows=2 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Partial Aggregate  (cost=4558.10..4558.11 rows=1 width=8) (actual rows=1 loops=2)
               ->  Parallel Seq Scan on events  (cost=0.00..4116.92 rows=176474 width=0) (actual rows=150000 loops=2)
                     Filter: (kind = 'view'::text)
                     Rows Removed by Filter: 2
```

Los números de costo exactos varían un poco de una ejecución a otra, así que no los compares dígito por dígito. La lección trata de cómo leer un plan. **Compara `rows=` con `actual rows=` en cada nodo.** Cuando difieren en órdenes de magnitud, el problema es la estimación, no el índice. Ejecuta `ANALYZE` después de una carga masiva o de un relleno grande de datos, y revisa `last_autoanalyze` en `pg_stat_user_tables` si sospechas que autovacuum se está quedando atrás. Para una columna con distribución sesgada, subir el objetivo de estadísticas (`ALTER TABLE … ALTER COLUMN … SET STATISTICS 500`) le da al planificador una imagen más fina que el valor por defecto de 100.

## 6. El índice multicolumna está en el orden equivocado

Un índice B-tree multicolumna se ordena por su primera columna, luego por la segunda dentro de cada valor de la primera, y así sucesivamente. Una consulta que filtra por la columna inicial puede usarlo bien. Una consulta que omite la columna inicial no puede:

```sql
CREATE INDEX users_org_created_idx ON users (org_id, created_at);
ANALYZE users;

-- leading column constrained: the index is used
EXPLAIN (COSTS OFF)
SELECT * FROM users WHERE org_id = 7 AND created_at >= '2026-01-02';

-- leading column missing: it is not
EXPLAIN (COSTS OFF)
SELECT * FROM users
WHERE created_at >= '2026-01-02 00:00:00' AND created_at < '2026-01-02 00:05:00';
```

```text
 Bitmap Heap Scan on users
   Recheck Cond: ((org_id = 7) AND (created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone))
   ->  Bitmap Index Scan on users_org_created_idx

 Gather
   Workers Planned: 1
   ->  Parallel Seq Scan on users
         Filter: ((created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone) AND (created_at < ...
```

Si necesitas filtrar solo por `created_at`, necesitas un índice que empiece por ella. Poner primero la columna con condiciones de igualdad y al final la columna de rango es la regla habitual para los índices que atienden ambos tipos de consulta:

```sql
CREATE INDEX users_created_org_idx ON users (created_at, org_id);
EXPLAIN (COSTS OFF)
SELECT * FROM users
WHERE created_at >= '2026-01-02 00:00:00' AND created_at < '2026-01-02 00:05:00';
```

```text
 Index Scan using users_created_org_idx on users
   Index Cond: ((created_at >= '2026-01-02 00:00:00+00'::timestamp with time zone) AND (created_at < ...
```

**Este caso depende de tu versión.** PostgreSQL 18 añadió skip scans para los índices B-tree, que permiten que una consulta que omite la columna inicial use el índice en algunos casos. Probamos la misma tabla y el mismo índice `(org_id, created_at)` en PostgreSQL 18.6, y allí la consulta de rango solo sobre `created_at` usó el índice `(org_id, created_at)`. En PostgreSQL 17, que es el que ejecuta Relyt One, no lo hace. Un skip scan solo vale la pena cuando el planificador espera saltarse pocos valores distintos de la columna inicial o cuando la condición posterior es selectiva, como lo era aquí, así que un índice diseñado para la consulta sigue siendo la opción más confiable.

Además, cada índice tiene un costo en cada escritura. Antes de agregar uno, comprueba que un índice existente no cubra ya la consulta.

## 7. La tabla es pequeña, o el índice no vale la pena

Una tabla de veinte filas cabe en una página. Leer esa página es más barato que leer una página del índice y luego una de la tabla, así que el planificador recorre la tabla:

```sql
CREATE TABLE plans (id int PRIMARY KEY, name text);
INSERT INTO plans SELECT g, 'plan' || g FROM generate_series(1, 20) g;
ANALYZE plans;

EXPLAIN (COSTS OFF) SELECT * FROM plans WHERE id = 7;
```

```text
 Seq Scan on plans
   Filter: (id = 7)
```

Ese es el comportamiento correcto, y es una trampa cuando pruebas en una base de datos de desarrollo pequeña y esperas el plan que tendrás en producción, o al revés. Prueba las decisiones sobre índices con datos que se parezcan a los de producción, en tamaño y en distribución.

También hay un interruptor de diagnóstico para los momentos en que no puedes distinguir si el índice es *posible* o simplemente *no fue elegido*. Desactiva los sequential scans durante una transacción y mira el plan:

```sql
BEGIN;
SET LOCAL enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT * FROM plans WHERE id = 7;
ROLLBACK;
```

```text
 Index Scan using plans_pkey on plans
   Index Cond: (id = 7)
```

Si el índice aparece, era utilizable y el planificador juzgó más barato el recorrido de la tabla, así que revisa las estadísticas, la selectividad y el tamaño de la tabla. Si sigue sin aparecer, el índice no puede atender la consulta tal como está escrita, así que vuelve a los casos 1, 2, 3 y 6. Usa este interruptor solo para diagnosticar. No lo dejes activado y no lo pongas en el código de la aplicación.

## ¿Qué índices de mi base de datos nunca se usan?

El problema opuesto son los índices que nadie usa. Hacen más lentas todas las inserciones y actualizaciones y ocupan espacio. `pg_stat_user_indexes` cuenta los escaneos de índice desde la última vez que se reiniciaron las estadísticas:

```sql
SELECT count(*) FROM users WHERE email = 'User42@Example.com';
SELECT count(*) FROM users WHERE email = 'User43@Example.com';

SELECT pg_sleep(1.5);  -- las estadísticas se reportan con un breve retraso

SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE relname = 'users'
ORDER BY idx_scan DESC, indexrelname;
```

Después de ejecutar dos veces la consulta de coincidencia exacta sobre `email` en la tabla de prueba, y de esperar un momento a que se reportaran las estadísticas, la salida fue:

```text
     indexrelname      | idx_scan
-----------------------+----------
 users_email_idx       |        2
 users_banned_idx      |        0
 users_created_org_idx |        0
 users_email_trgm_idx  |        0
 users_lower_email_idx |        0
 users_org_created_idx |        0
 users_pkey            |        0
```

Dos advertencias. `EXPLAIN` por sí solo no cuenta como un escaneo, así que solo aparecen las ejecuciones reales. Y un índice con cero escaneos es un candidato para eliminarse, no una prueba: puede existir para hacer cumplir una restricción de unicidad, o para un reporte mensual que no se ha ejecutado desde que se reiniciaron las estadísticas.

## Una lista de verificación para la próxima consulta lenta

1. Ejecuta `EXPLAIN (ANALYZE, BUFFERS)` y encuentra el nodo que domina.
2. En su línea `Filter`, busca una función o una conversión aplicada a la columna (casos 1 y 2).
3. Si el patrón empieza con `%`, considera un índice de trigramas (caso 3).
4. Compara `rows=` con `actual rows=`. Una gran diferencia significa `ANALYZE` (caso 5).
5. Comprueba con cuántas filas coincide la condición. Si es la mayor parte de la tabla, el escaneo es correcto (caso 4).
6. En un índice multicolumna, comprueba si la consulta restringe la columna inicial (caso 6).
7. Prueba con datos realistas y usa `SET LOCAL enable_seqscan = off` para distinguir "inutilizable" de "no elegido" (caso 7).

## Prueba cambios de índices sin tocar producción

En una tabla grande, construir un índice es la parte costosa: un `CREATE INDEX` normal bloquea las escrituras mientras se ejecuta, y `CREATE INDEX CONCURRENTLY` lo evita a cambio de una construcción más lenta. En Relyt One cada rama tiene su propio cómputo y almacenamiento copy-on-write, así que puedes crear una rama de tu base de datos, construir allí el índice, ejecutar `EXPLAIN (ANALYZE)` sobre tus datos reales y descartar la rama si el plan no mejora. La rama principal nunca construye el índice y nunca ve el experimento. Compara planes y estimaciones de filas entre la rama y producción, no duraciones: el cómputo de la rama puede tener otro tamaño y empieza con la caché fría.

[Empieza gratis](/auth/signin?ref=blog-postgres-not-using-index)
