Blogs / Tipos de Índices en Postgres: Cuál Usar y Cuándo

Tipos de Índices en Postgres: Cuál Usar y Cuándo

Publicado
2 de septiembre de 2026
Autor
Faizan Nadeem
Etiquetas
PostgreSQL Database Design Backend Development
Primer plano de estanterías de biblioteca repletas de libros, fotografiadas en ángulo a lo largo de la fila con el extremo lejano del pasillo desenfocado
Foto de Jamie Taylor en Unsplash

Pregunta a la mayoría de los desarrolladores qué es un índice de Postgres y la respuesta será “un B-tree que hace rápidas las cláusulas WHERE”. Eso es cierto quizá para el 80% de los índices en un esquema típico, y es exactamente la suposición que se desmorona la primera vez que alguien añade WHERE tags @> ARRAY['urgent'] o WHERE ST_DWithin(location, ..., 500) a una consulta y se pregunta por qué un índice B-tree sobre esa columna no hace absolutamente nada. Postgres incluye cuatro métodos de acceso a índices genuinamente distintos por una razón: B-tree, GIN, GiST y BRIN resuelven cada uno una forma diferente de “encontrar rápidamente filas que coincidan con esta condición”, y recurrir al predeterminado por costumbre es cómo los equipos terminan con un índice que encarece las escrituras y nunca se usa en lecturas.

La habilidad real no está en memorizar cuatro siglas, sino en aprender a mirar la cláusula WHERE de una consulta y reconocer qué forma de búsqueda es en realidad: igualdad y rango sobre valores escalares, contención dentro de arrays o JSON, superposición y proximidad en tipos geométricos o de rango, o una simple correlación con el orden físico de las filas en una tabla enorme. Una vez que puedes nombrar la forma, el tipo de índice prácticamente se elige solo. Esta publicación recorre los cuatro, cuánto cuesta cada uno en escrituras y dónde los índices parciales y de cobertura cambian por completo el cálculo.

Aprenderás:

  • Para qué está estructurado realmente cada uno de los cuatro tipos principales de índice
  • Por qué B-tree es el valor predeterminado correcto y cuándo deja de ser suficiente
  • Cuándo usar un índice GIN para columnas JSONB, arrays y búsqueda de texto completo
  • Para qué sirve GiST y en qué se diferencia de GIN en casos de uso que parecen similares
  • Por qué los índices BRIN son casi gratuitos en tablas enormes y ordenadas de forma natural
  • Cómo los índices parciales reducen tanto el tamaño del índice como el coste de escritura cuando una columna es casi siempre el mismo valor
  • Cómo los índices de cobertura permiten a Postgres omitir la tabla por completo con index-only scans

Tabla de Contenidos

  1. Lo básico: cuánto cuesta un índice
  2. B-tree: el predeterminado, y por qué
  3. GIN: contención y búsqueda de texto completo
  4. GiST: superposición, proximidad y exclusión
  5. BRIN: casi gratis en tablas enormes y ordenadas
  6. Índices parciales: indexar solo lo que importa
  7. Índices de cobertura e index-only scans
  8. Elegir según la forma de la consulta
  9. Errores comunes

Lo básico: cuánto cuesta un índice

Todo índice es una compensación, no una victoria gratuita. En lecturas, un índice bien ajustado convierte un recorrido completo de la tabla en una búsqueda dirigida. En escrituras, cada INSERT, UPDATE o DELETE que toca una columna indexada tiene que actualizar también cada índice que cubre esa columna: más índices significa escrituras más lentas, más espacio en disco y más trabajo para autovacuum. Por eso “simplemente añade un índice” no es una respuesta universal; es apostar a que el ahorro en lectura compensa el coste de escritura, y esa apuesta es distinta para cada tabla según su proporción de lectura/escritura.

Las vistas pg_indexes y pg_stat_user_indexes de Postgres son la fuente de verdad más honesta para saber si esa apuesta valió la pena:

SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan;

Un índice que permanece en idx_scan = 0 tras semanas de tráfico en producción es puro sobrecoste de escritura sin beneficio de lectura: un firme candidato a ser eliminado. Antes de lanzarte a un nuevo tipo de índice, vale la pena leer un plan con suficiente atención como para saber qué forma de scan está ocurriendo realmente; la guía de EXPLAIN ANALYZE cubre cómo distinguir un sequential scan que pide a gritos un índice de uno que de verdad es la opción más barata.

B-tree: el predeterminado, y por qué

B-tree es lo que construye CREATE INDEX a menos que indiques lo contrario, y es el predeterminado correcto porque es la única estructura que maneja eficientemente consultas de igualdad y de rango ordenado — =, <, >, BETWEEN, ORDER BY e IN — sobre tipos escalares como enteros, texto, timestamps y UUIDs.

CREATE INDEX idx_orders_created_at ON orders (created_at);

-- Uses the index for both of these:
SELECT * FROM orders WHERE created_at > now() - interval '7 days';
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;

Un índice B-tree compuesto — construido sobre varias columnas — está ordenado primero por su columna principal, luego por la siguiente, y así sucesivamente, lo que significa que el orden de las columnas en la sentencia CREATE INDEX no es cosmético. Un índice sobre (customer_id, status) sirve consultas que filtran solo por customer_id o por customer_id AND status juntas, pero no hace nada para una consulta que filtra solo por status, porque el índice no está ordenado por status en el nivel superior. Pon primero la columna con el valor más selectivo y que con más frecuencia se filtra por sí sola.

B-tree deja de ser una buena opción en el momento en que la condición ya no es “¿este valor escalar cae dentro de este rango?”: contención (@>, ?), texto completo (@@), superposición geométrica y similitud no son preguntas de rango, y un B-tree literalmente no puede responderlas sin degradarse a un recorrido completo.

Un índice GIN (Generalized Inverted Index) está construido para la pregunta opuesta a la de un B-tree: en lugar de “¿dónde se sitúa este valor individual en orden ordenado?”, responde “¿qué filas contienen este elemento dentro de un valor compuesto?”. Esa es exactamente la forma de una comprobación de contención sobre JSONB, una prueba de pertenencia a un array o una búsqueda de texto completo.

-- JSONB containment
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
SELECT * FROM products WHERE attributes @> '{"color": "red"}';

-- Array containment
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
SELECT * FROM articles WHERE tags @> ARRAY['postgres'];

-- Full-text search
CREATE INDEX idx_articles_search ON articles USING GIN (to_tsvector('english', body));
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('index & performance');

Internamente, un índice GIN almacena un mapeo desde cada elemento individual (cada par clave/valor JSON, cada elemento del array, cada lexema) de vuelta a las filas que lo contienen: un índice invertido, la misma estructura que usa un motor de búsqueda. Esa estructura es lo que hace rápida la contención, y también por eso los índices GIN son notablemente más caros de mantener en escritura que B-tree: insertar una fila puede significar actualizar decenas de entradas individuales si el documento JSONB o el array de esa fila tiene decenas de elementos.

El mecanismo fastupdate de Postgres (activado por defecto) agrupa esas actualizaciones en una lista pendiente en lugar de escribir directamente en el árbol del índice en cada inserción, intercambiando un coste de mantenimiento en segundo plano por una menor latencia por escritura. En una tabla con muchas escrituras y un índice GIN, vigila pg_stat_user_tables para la frecuencia de autovacuum: una tabla cargada de GIN suele necesitar configuraciones de autovacuum más agresivas que las predeterminadas.

GIN también soporta índices multicolumna desde Postgres 9.4 en adelante, lo cual importa para un patrón común: filtrar por una columna JSONB y una columna escalar simple en la misma consulta.

CREATE EXTENSION IF NOT EXISTS btree_gin;

CREATE INDEX idx_products_multi ON products
USING GIN (tenant_id, attributes);

SELECT * FROM products
WHERE tenant_id = 42 AND attributes @> '{"in_stock": true}';

La extensión btree_gin permite que una columna escalar como tenant_id participe en un índice GIN junto a una columna jsonb o de array, de modo que un solo índice pueda servir a un filtro combinado en lugar de obligar al planner a elegir entre un B-tree sobre tenant_id y un índice GIN sobre attributes y luego combinar ambos conjuntos de resultados con bitmap-AND. Que el índice combinado o dos índices separados sea más rápido depende de la selectividad: mide ambas opciones con EXPLAIN (ANALYZE, BUFFERS) en lugar de asumirlo.

GiST: superposición, proximidad y exclusión

GiST (Generalized Search Tree) se parece a GIN en la superficie — ambos manejan datos no escalares — pero las preguntas que responden son diferentes. GIN responde “¿esta fila contiene este elemento exacto?”. GiST responde “¿el valor de esta fila se superpone, intersecta o está cerca de este otro valor?”, lo que es una estructura fundamentalmente distinta y más imprecisa, construida alrededor de regiones delimitadoras en lugar de búsquedas exactas por elemento.

-- Geometric proximity (with the earthdistance/cube or PostGIS extension)
CREATE INDEX idx_venues_location ON venues USING GIST (location);
SELECT name FROM venues
WHERE ST_DWithin(location, ST_MakePoint(-122.42, 37.77), 5000);

-- Range overlap
CREATE INDEX idx_bookings_period ON bookings USING GIST (during);
SELECT * FROM bookings WHERE during && tsrange('2026-08-01', '2026-08-05');

-- Exclusion constraints (no overlapping bookings for the same room)
ALTER TABLE bookings ADD CONSTRAINT no_overlap
  EXCLUDE USING GIST (room_id WITH =, during WITH &&);

Vale la pena destacar ese último ejemplo por sí solo: GiST es la única de estas cuatro estructuras que respalda una restricción EXCLUDE, que es como Postgres impone “no puede haber dos filas con rangos superpuestos para la misma clave” a nivel de base de datos en lugar de hacerlo en código de aplicación. Esto es genuinamente difícil de hacer bien solo con locking a nivel de fila y lógica de aplicación, y la restricción a nivel de base de datos cierra condiciones de carrera que el código de aplicación suele pasar por alto bajo concurrencia.

GiST y GIN admiten ambas las mismas operator classes para algunos tipos (como jsonb), y cuando ambas aplican, GIN suele ser más rápido en consulta pero más lento de construir y más grande en disco, mientras que GiST se construye más rápido y sigue siendo más pequeño a costa de algo de velocidad de consulta. Comprueba ambos si estás en el límite y la elección no está dictada específicamente por necesitar EXCLUDE u operadores geométricos.

BRIN: casi gratis en tablas enormes y ordenadas

BRIN (Block Range Index) adopta un enfoque completamente distinto: en lugar de indexar filas individuales, almacena el valor mínimo y máximo para cada rango físico de bloques (128 páginas por defecto). Eso lo hace dramáticamente más pequeño que un B-tree — a menudo entre dos y tres órdenes de magnitud más pequeño — a costa de ser mucho menos preciso: un índice BRIN solo puede decirte qué rangos de bloques podrían contener una fila coincidente, no qué filas exactas lo hacen, así que Postgres aún tiene que volver a comprobar las filas candidatas frente a la condición real.

CREATE INDEX idx_events_logged_at ON events USING BRIN (logged_at);

Esta es la herramienta adecuada específicamente cuando una columna se correlaciona fuertemente con el orden físico de inserción: un timestamp created_at o logged_at en una tabla append-only es el caso canónico, porque las filas insertadas aproximadamente al mismo tiempo realmente viven cerca unas de otras en disco. Una tabla de series temporales o de logs de eventos con cien millones de filas puede tener un índice BRIN de unos pocos cientos de kilobytes, frente a un B-tree sobre la misma columna que entra en los cientos de megabytes, con un rendimiento de consulta lo bastante cercano en cargas dominadas por range scans como para que la compensación merezca la pena.

BRIN es la elección equivocada en cuanto la columna indexada deja de correlacionarse con el orden físico de las filas: una columna status que se actualiza in place dispersa por toda la tabla, por ejemplo, destruye toda la premisa, porque el mínimo/máximo por rango de bloques deja de significar algo útil.

Índices parciales: indexar solo lo que importa

Un índice parcial añade una cláusula WHERE al propio CREATE INDEX, de modo que solo las filas que coinciden con esa condición se indexan en absoluto. Esta es, con diferencia, la herramienta más infrautilizada de esta lista, y responde directamente a cuándo usar un índice parcial de Postgres en lugar de uno completo: siempre que una columna esté fuertemente sesgada hacia un valor por el que tus consultas en realidad nunca filtran.

CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

Si el 95% de las órdenes son completed o cancelled y la aplicación solo consulta las pending en este flujo, un índice completo sobre status desperdicia la mayor parte de su tamaño cubriendo filas que nadie busca de esa manera. El índice parcial de arriba es una fracción del tamaño de un índice completo sobre la misma columna, más barato de mantener en cada escritura sobre una fila no pendiente (porque esas escrituras no tocan este índice en absoluto) y, al ser más pequeño, más propenso a permanecer residente en caché. El mismo patrón se aplica a soft-deletes (WHERE deleted_at IS NULL), feature flags y cualquier columna tipo enum con un valor dominante que rara vez se consulta.

Índices de cobertura e index-only scans

Un recorrido normal por índice todavía tiene que visitar la tabla subyacente (el “heap”) para recuperar columnas que no están en el propio índice. Un índice de cobertura, construido con INCLUDE, almacena columnas extra dentro de las páginas hoja del índice únicamente para que la consulta pueda resolverse solo desde el índice, omitiendo por completo el heap: un index-only scan.

CREATE INDEX idx_orders_customer_covering
ON orders (customer_id, status) INCLUDE (total, created_at);

EXPLAIN (ANALYZE, BUFFERS)
SELECT total, created_at FROM orders
WHERE customer_id = 4821 AND status = 'pending';
-- Index Only Scan using idx_orders_customer_covering

Las columnas INCLUDE no forman parte de la clave de ordenación del índice: no ayudan con el filtrado ni con el ordenamiento, simplemente viajan junto con él para que la búsqueda en el heap deje de ser necesaria para las consultas que solo necesitan devolver esas columnas. Esto intercambia tamaño del índice (las columnas incluidas duplican datos que ya viven en la tabla) por velocidad de lectura, y solo compensa plenamente cuando el mapa de visibilidad de la tabla está bien mantenido por autovacuum: un index-only scan todavía necesita comprobar la visibilidad del heap para páginas que no han sido vacuumed recientemente, así que una tabla con mucho churn y sin una configuración adecuada de autovacuum no verá el beneficio completo.

Elegir según la forma de la consulta

  • Igualdad o rango sobre una columna escalar, ordenamiento o valor predeterminado de propósito general: B-tree.
  • “¿Este JSONB/array contiene X?” o búsqueda de texto completo: GIN.
  • Superposición geométrica, proximidad, superposición de rangos o una restricción EXCLUDE: GiST.
  • Tabla enorme, mayormente append-only, donde la columna se correlaciona con el orden de inserción: BRIN.
  • Una columna sesgada donde las consultas solo se preocupan por el valor raro: un índice parcial, combinado con el tipo base que se ajuste a la columna.
  • Una consulta caliente que solo necesita devolver unas pocas columnas: un índice de cobertura con INCLUDE, además del tipo que encaje con el filtro en sí.

No son mutuamente excluyentes: un índice GIN parcial, o un B-tree de cobertura con una cláusula WHERE, son ambos totalmente válidos y a menudo la mejor respuesta una vez que has identificado tanto la forma de la consulta como el sesgo en los datos.

Tipo de índiceRespondeCoste relativo de construcción/escrituraTamaño relativoUso típico
B-treeigualdad, rango, ordenaciónbajomoderadoclaves primarias, claves foráneas, columnas de ORDER BY
GINcontención, texto completoaltomoderado–grandeJSONB, arrays, tsvector
GiSTsuperposición, proximidad, exclusiónmoderadomoderadogeometría, rangos, restricciones EXCLUDE
BRINreducción de rango correlacionadomuy bajodiminutoseries temporales append-only, tablas de logs enormes

Trata esta tabla como un punto de partida, no como un veredicto: la única forma de saber qué índice ayuda realmente a una consulta concreta es construirlo y comparar EXPLAIN (ANALYZE, BUFFERS) antes y después, ya que la selectividad real sobre tus datos reales puede inclinar la compensación en cualquiera de las dos direcciones.

Hay una cosa más que vale la pena convertir en hábito desde el principio: ninguno de estos tipos de índice debería construirse con un CREATE INDEX a secas sobre una tabla que ya está atendiendo tráfico de producción. Un CREATE INDEX normal toma un lock que bloquea las escrituras sobre la tabla durante toda la duración de la construcción, lo que en una tabla grande puede significar minutos de sentencias INSERT/UPDATE/DELETE bloqueadas. CREATE INDEX CONCURRENTLY construye el mismo índice — B-tree, GIN, GiST o BRIN, la opción aplica a los cuatro — sin mantener ese lock, a costa de una construcción más lenta y una pequeña posibilidad de tener que reintentarlo si se interrumpe a mitad del proceso.

Errores comunes

Error: añadir un índice B-tree y esperar que acelere una consulta de contención sobre JSONB. Un B-tree sobre una columna jsonb puede soportar igualdad sobre el documento completo, pero no contención @>. Solución: usa GIN para contención, texto completo y pertenencia a arrays.

Error: dejar índices GIN con fastupdate sin supervisión en una tabla con muchas escrituras. La lista pendiente puede crecer lo suficiente como para que autovacuum tenga dificultades para mantenerse al día, y la latencia de consulta aumente como resultado. Solución: vigila pg_stat_user_tables y ajusta específicamente los umbrales de autovacuum para tablas cargadas de GIN.

Error: usar BRIN sobre una columna sin correlación con el orden físico. El índice parece existir, pero apenas reduce nada, y las consultas de todos modos terminan recorriendo la mayoría de los rangos de bloques candidatos. Solución: comprueba correlation en pg_stats para la columna antes de elegir BRIN.

Error: construir un índice B-tree compuesto con las columnas en el orden incorrecto. Un índice sobre (status, customer_id) no sirve a una consulta que filtra solo por customer_id tan bien como lo haría (customer_id, status). Solución: pon primero la columna usada con más frecuencia de forma aislada o con mayor selectividad.

Error: no revisar nunca pg_stat_user_indexes para detectar índices sin uso. Todo índice que no se usa realmente es puro sobrecoste de escritura. Solución: revisa idx_scan periódicamente y elimina los índices que sigan en cero tras un ciclo completo de tráfico.

Para cerrar

No existe un único índice “correcto” de Postgres: existe un índice correcto para la forma específica de la consulta que realmente estás ejecutando, y Postgres te da cuatro herramientas estructuralmente distintas porque las búsquedas de rango escalar, la contención, la superposición y la correlación en tablas enormes son cuatro problemas genuinamente diferentes. B-tree cubre la mayor parte de lo que necesita un esquema típico; GIN y GiST cubren los casos de JSONB, arrays, texto completo y geometría que un B-tree no puede tocar en absoluto; BRIN aporta enormes ahorros de espacio en tablas enormes y ordenadas de forma natural; y los índices parciales y de cobertura son modificadores que hacen cualquiera de los anteriores más barato o más rápido una vez que entiendes el sesgo real y el patrón de acceso de tus datos.

La próxima vez que recurras a CREATE INDEX, ¿lo estás ajustando a la forma real de la consulta o estás recurriendo a B-tree por costumbre?

Más artículos