Tipos de Indices en Postgres Explicados: B-tree, GIN, BRIN y los Operadores que los Eligen
Un recorrido practico por los tipos de indices de PostgreSQL con planes de consulta reales: como funcionan B-tree, GIN y BRIN, que son las operator classes y como los operadores de tu WHERE deciden que indice te conviene.
En mi post sobre JSON y JSONB te dije que crearas un índice GIN con jsonb_path_ops y seguí de largo, porque ese post era sobre un tipo de columna y no sobre indexado. Este viene a discutir lo que faltaba. Qué es de verdad un índice GIN, y por qué Postgres trae cinco tipos de índice más.
Todos los ejemplos de abajo corren tal cual en un Postgres 17 estándar. Los planes y tamaños son salida real de mi máquina.
Un índice es una apuesta
Un índice es una estructura de datos separada que cambia velocidad de escritura y disco por velocidad de lectura. Cada insert y cada update tienen que mantener todos los índices de la tabla, y cada índice ocupa espacio real. La pregunta nunca debería ser “¿debería indexar esta tabla?” sino “¿qué lecturas valen el impuesto sobre mis escrituras?”. Un índice que ninguna consulta usa es impuesto sin sentido.
Los índices aceleran operadores, no columnas
Cuando el planificador considera un índice, no pregunta “¿hay un índice sobre esta columna?”. Pregunta “¿hay un índice cuyo tipo sepa responder este operador?”. Un B-tree sabe responder =, <, <=, >=, > y BETWEEN, porque mantiene los valores ordenados. No tiene idea de qué hacer con el operador de contención de arrays @>. Un índice GIN responde @> nativamente y no te puede ayudar con <.
El aglutinante entre un tipo de índice y los operadores que atiende se llama operator class. La mayor parte del tiempo la clase default es la que querés y nunca escribís su nombre. El momento en que se vuelve práctica es cuando un tipo de índice ofrece una elección, que es exactamente la decisión jsonb_ops versus jsonb_path_ops del post de JSONB: la misma maquinaria GIN, distinto conjunto de operadores soportados, distinto tamaño.
Cada tipo de índice de abajo es solo una respuesta distinta a la pregunta “¿qué operadores necesitás que sean rápidos?”.
La tabla de ejemplo
Una tabla con un millón de filas, con forma de log de eventos porque ahí es donde las decisiones de indexado se ponen interesantes: un id de usuario para buscar, un status que casi siempre es ok, un array de tags y un timestamp que crece con el orden de inserción.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id int NOT NULL,
status text NOT NULL,
tags text[] NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO events (user_id, status, tags, created_at)
SELECT
i % 50000,
CASE WHEN i % 211 = 0 THEN 'failed' ELSE 'ok' END,
ARRAY['app' || i % 7, (ARRAY['auth', 'billing', 'search', 'export', 'sync'])[1 + i % 5]]
|| CASE WHEN i % 397 = 0 THEN ARRAY['beta'] ELSE '{}' END,
timestamptz '2026-01-01 00:00:00+00' + i * interval '2 seconds'
FROM generate_series(0, 999999) AS i;
ANALYZE events;
Cómo sería sin un índice
Pedí los eventos de un usuario sin nada más que la primary key:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT * FROM events WHERE user_id = 12345;
Gather (actual rows=20 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on events (actual rows=7 loops=3)
Filter: (user_id = 12345)
Rows Removed by Filter: 333327
Planning Time: 0.083 ms
Execution Time: 15.696 ms
Postgres leyó la tabla entera y le tiró dos workers paralelos. Veinte filas coincidentes requirieron inspeccionar un millón. Un scan secuencial es lo que tomamos como línea base, y en tablas chicas suele ser genuinamente el plan más rápido. Sobre un millón de filas, para veinte coincidencias, es lo que justifica la existencia de los índices.
B-tree, el default
CREATE INDEX sin cláusula USING te da un B-tree, un árbol balanceado de valores ordenados. Ese orden es la razón por la que cubre el conjunto más amplio de operadores: igualdad, todas las comparaciones, BETWEEN, y puede alimentar un ORDER BY sin paso de ordenamiento.
CREATE INDEX events_user_id_idx ON events (user_id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT * FROM events WHERE user_id = 12345;
Bitmap Heap Scan on events (actual rows=20 loops=1)
Recheck Cond: (user_id = 12345)
Heap Blocks: exact=20
-> Bitmap Index Scan on events_user_id_idx (actual rows=20 loops=1)
Index Cond: (user_id = 12345)
Planning Time: 0.087 ms
Execution Time: 0.046 ms
De 15.7 milisegundos a 0.046. La misma consulta, los mismos datos, trescientas veces más rápido.
Un bitmap scan es una estrategia en dos fases. El Bitmap Index Scan recorre el índice y junta las ubicaciones de cada fila coincidente en un bitmap en memoria. El Bitmap Heap Scan después ordena esas ubicaciones por página y visita cada página de la tabla exactamente una vez. Cuando las coincidencias están desparramadas por la tabla, como acá, esto le gana a saltar ida y vuelta entre índice y tabla fila por fila. Uso este formato de plan con cada tipo de índice de este post, porque GIN y BRIN producen sus resultados como bitmaps por naturaleza.
El mismo B-tree atiende consultas por rango sin costo:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT * FROM events WHERE user_id BETWEEN 100 AND 199;
Bitmap Heap Scan on events (actual rows=2000 loops=1)
Recheck Cond: ((user_id >= 100) AND (user_id <= 199))
Heap Blocks: exact=41
-> Bitmap Index Scan on events_user_id_idx (actual rows=2000 loops=1)
Index Cond: ((user_id >= 100) AND (user_id <= 199))
Planning Time: 0.048 ms
Execution Time: 0.137 ms
Si tu condición es igualdad u orden sobre un escalar, el B-tree es casi siempre la respuesta, y por eso es el default.
GIN, el índice invertido
Un B-tree guarda una entrada por fila. Ese modelo colapsa cuando un solo valor contiene muchos elementos buscables: un array de tags, las claves de un documento jsonb, las palabras de un texto. No querés una entrada por fila, querés una entrada por elemento, apuntando de vuelta a cada fila que lo contiene. Esa estructura es un índice invertido, y en Postgres se llama GIN, por Generalized Inverted Index.
CREATE INDEX events_tags_idx ON events USING gin (tags);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT * FROM events WHERE tags @> ARRAY['beta'];
Bitmap Heap Scan on events (actual rows=2519 loops=1)
Recheck Cond: (tags @> '{beta}'::text[])
Heap Blocks: exact=2519
-> Bitmap Index Scan on events_tags_idx (actual rows=2519 loops=1)
Index Cond: (tags @> '{beta}'::text[])
Planning Time: 0.105 ms
Execution Time: 1.712 ms
El operador de contención @> equivale a preguntar “¿este array contiene estos elementos?”, el índice GIN busca beta en su catálogo de elementos, y vuelven 2.519 filas sin tocar las otras 997.481. Cambiá el array por una columna jsonb y este es exactamente el par de índice y operador del post de JSONB. La búsqueda full-text corre sobre la misma maquinaria.
El lado del costo: GIN es el índice más caro de mantener en escrituras de todos los que muestro, porque el insert de una fila puede agregar muchas entradas al índice. Se gana ese costo solo cuando tus consultas hacen preguntas de contención de verdad.
BRIN
BRIN, Block Range Index, no guarda ubicaciones de filas. Guarda un resumen por rango de páginas de la tabla, por defecto el mínimo y el máximo encontrados en cada rango de 128 páginas. Una consulta por un rango de valores le permite a Postgres saltearse cada rango de bloques cuyo resumen no puede contener una coincidencia.
Eso solo funciona cuando el layout físico se correlaciona con los valores, que es precisamente la situación de un timestamp en una tabla append-only: las filas llegan en orden temporal, así que cada rango de bloques cubre una franja angosta de tiempo.
CREATE INDEX events_created_brin ON events USING brin (created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM events
WHERE created_at >= '2026-01-10' AND created_at < '2026-01-11';
Aggregate (actual rows=1 loops=1)
-> Bitmap Heap Scan on events (actual rows=43200 loops=1)
Recheck Cond: ((created_at >= '2026-01-10 00:00:00+00'...))
Rows Removed by Index Recheck: 13120
Heap Blocks: lossy=640
-> Bitmap Index Scan on events_created_brin (actual rows=6400 loops=1)
Index Cond: (...)
Planning Time: 0.093 ms
Execution Time: 3.755 ms
Fijate en lossy=640 y en el recheck sacando 13.120 filas. BRIN no puede decir “la fila 5 coincide”, solo “algo en estas páginas podría coincidir”, así que Postgres visita las páginas candidatas y filtra. Esa imprecisión es el precio del bajo requerimiento de almacenamiento. Como se puede ver:
CREATE INDEX events_created_btree ON events (created_at);
SELECT relname AS index_name, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relname IN ('events_created_brin', 'events_created_btree');
index_name | size
----------------------+-------
events_created_brin | 24 kB
events_created_btree | 21 MB
Veinticuatro kilobytes contra veintiún megabytes para la misma columna, un factor de casi mil. En tablas de logs, métricas y eventos que solo crecen, BRIN te ofrece un espacio de disco negligible y un overhead de escritura casi nulo, a cambio de menor precisión. Pero en columnas sin correlación física no te ofrece nada, que es el trade-off resumido en una oración.
Índices parciales y de expresión
Estos no son tipos de índice sino modificadores que aplican a cualquiera de los de arriba, y resuelven problemas muy comunes.
Un índice parcial lleva una cláusula WHERE y solo indexa las filas que coinciden. Mi columna status es failed en menos de 0.5% de las filas, y las filas failed son las únicas que busco por status:
CREATE INDEX events_failed_idx ON events (created_at) WHERE status = 'failed';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT * FROM events
WHERE status = 'failed' AND created_at >= '2026-01-20';
Index Scan using events_failed_idx on events (actual rows=849 loops=1)
Index Cond: (created_at >= '2026-01-20 00:00:00+00'...)
Planning Time: 0.226 ms
Execution Time: 0.515 ms
El índice pesa 120 kB contra 21 MB de su equivalente sobre toda la tabla, y cada insert de una fila ok se lo saltea por completo. Este plan además es un Index Scan a secas y no un bitmap: son suficientemente pocas filas, así que Postgres camina el índice y trae las filas directo.
Un índice de expresión indexa el resultado de una expresión en lugar de una columna cruda, que es como indexás lower(email), o una sola clave muy frecuente extraída de un documento jsonb:
CREATE INDEX chunks_source_type_idx ON chunks ((metadata ->> 'source_type'));
Esa línea está sacada directo del post de JSONB, y vale la pena repetir qué hace: le da a una clave JSON un B-tree común con estadísticas comunes, sin GIN de por medio.
Los costos
Todo lo de arriba, medido. Esta es la tabla y todos los índices que este post creó sobre ella:
relname | size
----------------------+------------
events | 89 MB
events_pkey | 21 MB
events_created_btree | 21 MB
events_user_id_idx | 7600 kB
events_tags_idx | 2360 kB
events_failed_idx | 120 kB
events_created_brin | 24 kB
Los índices juntos suman más de la mitad del tamaño de la tabla misma, y cada fila escrita paga mantenimiento en todos. Por eso “agregale un índice” no es un consejo gratuito, y por eso los tamaños abarcan tres órdenes de magnitud para el mismo trabajo sobre los mismos datos.
Dejá que el operador elija
Después de todo esto, el procedimiento de decisión es corto, porque el operador de tu WHERE ya lo tomó. Si es igualdad y rangos sobre escalares, usá un B-tree. Si son preguntas de contención contra arrays, jsonb o búsqueda de texto, usá GIN. Si son rangos de tiempo sobre tablas enormes append-only, usá BRIN. Después los modificadores parciales y de expresión acotan el que elegiste a las filas y expresiones que consultás de verdad.
Cuando un plan te sorprende, leelo con el lente del operador: EXPLAIN ANALYZE te dice qué índice respondió qué condición, y un scan secuencial suele significar que ningún índice de la tabla habla el operador que usaste, o que la tabla es tan chica que hablarlo no importa.
Si la línea de GIN de mi post de JSONB te trajo hasta acá, ya tenés el cuadro completo: el tipo de columna decide qué operadores existen, y los operadores deciden qué índice se gana su disco.