Índices PostgreSQL para apps SaaS: cómo elegir entre btree, GIN y GiST según formas reales de consulta como filtros, búsqueda, JSONB y arrays.

Un índice cambia la forma en que PostgreSQL encuentra filas. Sin un índice, la base de datos a menudo tiene que leer gran parte de la tabla (un escaneo secuencial) y luego descartar la mayor parte. Con el índice adecuado, puede saltar directamente a las filas coincidentes (una búsqueda por índice) y luego recuperar solo lo necesario.
Esto se nota pronto en SaaS porque las pantallas diarias hacen muchas consultas. Un solo clic puede desencadenar varias lecturas: la página de lista, un conteo total, un par de tarjetas del dashboard y un cuadro de búsqueda. Cuando una tabla crece de miles a millones de filas, la misma consulta que antes parecía instantánea empieza a retrasarse.
Un ejemplo típico es una página de Orders filtrada por estado y fecha, ordenada por más recientes primero, con paginación. Si PostgreSQL tiene que escanear toda la tabla de orders para encontrar pagos de los últimos 30 días, cada carga de página hace trabajo extra. Un buen índice convierte eso en un salto rápido a la porción correcta de datos.
Los índices no son gratis. Cada uno acelera lecturas para consultas específicas, pero también ralentiza escrituras (INSERT/UPDATE/DELETE deben actualizar los índices) y ocupa más almacenamiento (además de aumentar la presión de caché). Ese intercambio es la razón por la que deberías partir de patrones de consulta reales, no de tipos de índice.
Una regla simple que evita trabajo inútil: añade un índice solo cuando puedas señalar una consulta concreta y frecuente que vaya a acelerar. Si construyes pantallas con un generador guiado por chat como Koder.ai, ayuda capturar el SQL detrás de tus páginas de lista y dashboards y usarlo como lista de deseos de índices.
La mayor parte de la confusión sobre índices desaparece cuando dejas de pensar en características (JSON, búsqueda, arrays) y empiezas a pensar en la forma de la consulta: ¿qué hace la cláusula WHERE y cómo esperas que se ordenen los resultados?
Usa B-tree cuando tu consulta parece comparaciones normales y te importa el orden. Es el caballo de batalla para igualdad, rangos y joins.
Ejemplos de forma: filtrar por tenant_id = ?, status = 'active', created_at >= ?, hacer join users.id = orders.user_id, o mostrar “más recientes primero” con ORDER BY created_at DESC.
GIN (Generalized Inverted Index) encaja cuando una columna contiene muchos miembros y preguntas del tipo “¿contiene X?”. Es común con claves JSONB, elementos de arrays y vectores de texto completo.
Ejemplos de forma: metadata @> {'plan':'pro'} en JSONB, tags @> ARRAY['urgent'], o to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) encaja en preguntas sobre distancia u solapamiento, donde los valores se comportan como rangos o formas. Se usa a menudo para tipos range, datos geométricos y algunas búsquedas de “coincidencia más cercana”.
Ejemplos de forma: ventanas de tiempo solapadas con columnas range, búsquedas por similitud (por ejemplo, usando trigram operators), o consultas espaciales (si usas PostGIS).
Una forma práctica de elegir:
Los índices aceleran lecturas, pero cuestan en tiempo de escritura y disco. En SaaS, ese intercambio importa más en tablas calientes como events, sessions y activity logs.
La mayoría de pantallas de listas en SaaS comparten la misma forma: un límite por tenant, un par de filtros y un orden previsible. Los índices B-tree son la elección por defecto aquí y normalmente los más baratos de mantener.
Un patrón común es WHERE tenant_id = ? más filtros como status = ?, user_id = ? y un rango temporal como created_at >= ?. Para índices compuestos B-tree, pon primero filtros de igualdad (columnas que comparas con =), luego añade la columna por la que ordenas.
Reglas que funcionan bien en la mayoría de apps:
tenant_id si cada consulta está acotada por tenant.= (a menudo status, user_id).ORDER BY al final (a menudo created_at o id).INCLUDE para cubrir páginas de lista sin ensanchar la clave.Un ejemplo realista: una página de Tickets mostrando los ítems más nuevos primero, filtrada por estado.
-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);
Ese índice soporta tanto el filtro como el orden, así Postgres puede evitar ordenar un conjunto de resultados grande. La parte INCLUDE (title) ayuda a que la página de lista toque menos páginas de tabla, manteniendo las claves del índice centradas en el filtrado y el orden.
Para rangos temporales, la misma idea aplica:
SELECT id, created_at
FROM events
WHERE tenant_id = $1
AND created_at >= $2
AND created_at < $3
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);
La paginación es donde muchas apps SaaS se ralentizan. La paginación con OFFSET (OFFSET 50000) obliga a la base de datos a pasar por muchas filas. La paginación por seek se mantiene rápida usando la última clave vista:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at < $2
ORDER BY created_at DESC
LIMIT 50;
Con el B-tree adecuado, esto permanece rápido incluso cuando la tabla crece.
La mayoría de apps SaaS son multi-tenant: cada consulta debe quedarse dentro de un tenant. Si tus índices no incluyen tenant_id, Postgres aún puede encontrar filas rápido, pero a menudo escanea muchas más entradas de índice de las necesarias. Los índices conscientes del tenant mantienen los datos de cada tenant agrupados en el índice para que las pantallas comunes sigan siendo rápidas y predecibles.
Una regla simple: pon tenant_id primero en el índice cuando la consulta siempre filtra por tenant. Luego añade la columna que más filtras u ordenas.
Índices aburridos pero de alto impacto suelen verse así:
(tenant_id, created_at) para listas de elementos recientes y paginación por cursor(tenant_id, status) para filtros por estado (Open, Paid, Failed)(tenant_id, user_id) para pantallas de “ítems de este usuario”(tenant_id, updated_at) para vistas administrativas de “recientemente cambiados”(tenant_id, external_id) para búsquedas desde webhooks o importacionesEl sobreindexado ocurre cuando añades un índice nuevo para cada pantalla ligeramente distinta. Antes de crear otro, verifica si un índice compuesto existente ya cubre las columnas más a la izquierda que necesitas. Por ejemplo, si tienes (tenant_id, created_at), normalmente no necesitas también (tenant_id, created_at, id) a menos que realmente filtres por id después de esas columnas.
Los índices parciales pueden reducir tamaño y coste de escritura cuando la mayoría de filas no son relevantes. Funcionan bien con borrados suaves y datos “solo activos”, por ejemplo: indexar solo donde deleted_at IS NULL, o solo donde status = 'active'.
Cada índice extra hace que las escrituras sean más pesadas. Las inserciones deben actualizar cada índice, y las actualizaciones pueden tocar varios índices incluso si cambias una sola columna. Si tu app ingiere muchos eventos (incluyendo apps creadas rápidamente con Koder.ai), mantén los índices centrados en las pocas formas de consulta que los usuarios usan cada día.
JSONB es útil cuando tu app necesita campos extra flexibles como feature flags, atributos de usuario o configuración por tenant. El problema es que distintos operadores JSONB se comportan diferente, así que el mejor índice depende de cómo consultas.
Dos formas importan más:
@>.-> / ->> (a menudo comparado con =).Si filtras frecuentemente con @>, un índice GIN en la columna JSONB suele compensar.
-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata @> '{"region":"eu","plan":"pro"}';
-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);
Si tu estructura JSON es predecible y usas mayormente @> en claves de primer nivel, jsonb_path_ops puede ser más pequeño y rápido, pero soporta menos tipos de operadores.
Si tu UI filtra repetidamente por un campo (como plan), extraer ese campo e indexarlo suele ser más rápido y barato que un GIN amplio.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata->>'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata->>'plan'));
Una regla práctica: usa JSONB para atributos flexibles y poco consultados, pero promociona a columnas reales las claves estables y muy usadas (plan, status, created_at). Si iteras rápido en una app generada, a menudo es un ajuste fácil de esquema cuando ves qué filtros aparecen en cada página.
Ejemplo: si guardas {"tags":["beta","finance"],"region":"us"} en JSONB, usa GIN cuando filtras por conjuntos de atributos (@>), y añade índices de expresión para las pocas claves que mueven la mayoría de las vistas de lista (plan, region).
Los arrays son tentadores porque son fáciles de almacenar y de leer. Una columna users.roles text[] o projects.labels text[] puede funcionar bien cuando la pregunta principal es: ¿contiene esta fila un valor? Ahí es exactamente donde un índice GIN ayuda.
GIN es la elección para consultas de pertenencia en arrays. Descompone el array en elementos individuales y construye una búsqueda rápida de filas que los contienen.
Formas de consulta de arrays que suelen beneficiarse:
@> (array contains)&& (array comparte elementos)= ANY(...), pero @> es más predecibleUn ejemplo típico para filtrar usuarios por rol:
-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @> ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
Y filtrar proyectos por un conjunto de etiquetas (debe incluir ambas etiquetas):
SELECT id, name
FROM projects
WHERE labels @> ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
Donde la gente se sorprende: algunos patrones no usan el índice como esperas. Si conviertes el array a string (array_to_string(labels, ',')) y luego haces LIKE, el índice GIN no ayudará. Además, si necesitas “empieza por” o coincidencias difusas dentro de las etiquetas, eso es territorio de búsqueda de texto, no pertenencia de arrays.
Los arrays también pueden volverse difíciles de mantener cuando se transforman en una mini-base de datos: actualizaciones frecuentes, necesidad de metadatos por elemento (quién añadió la etiqueta, cuándo, por qué) o analítica por etiqueta. En ese punto, una tabla join como project_labels(project_id, label) suele ser más fácil de validar, consultar y evolucionar.
Para cuadros de búsqueda aparecen dos patrones una y otra vez: búsqueda de texto completo (encontrar registros sobre un tema) y coincidencia difusa (manejar errores tipográficos, nombres parciales y patrones ILIKE). El índice correcto marca la diferencia entre “instantáneo” y “timeouts a los 10k usuarios”.
Usa búsqueda de texto completo cuando los usuarios escriben palabras reales y quieres resultados ordenados por relevancia, como buscar tickets por asunto y descripción. La configuración habitual es almacenar un tsvector (a menudo en una columna generada) e indexarlo con GIN. Se busca con @@ y un tsquery.
-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);
-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');
-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);
SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';
Qué almacenar en el vector: solo los campos que realmente buscas. Si incluyes todo (notes, logs internos), pagas en tamaño de índice y coste de escritura.
Usa similitud por trigramas cuando los usuarios buscan nombres, emails o frases cortas y necesitas coincidencias parciales o tolerancia a errores. Los trigramas ayudan con ILIKE '%term%' y operadores de similitud. GIN suele ser más rápido para búsquedas de “coincide o no”; GiST puede encajar mejor cuando también te importa ordenar por similitud.
Reglas prácticas:
tsvector para búsqueda de texto basada en relevancia.Peligros a vigilar:
ILIKE '%abc') fuerzan escaneos.Si vas a lanzar pantallas de búsqueda rápido, trata el índice como parte de la funcionalidad: la UX de búsqueda y la elección de índice deben diseñarse juntas.
Empieza con la consulta exacta que ejecuta tu app, no con una suposición. Una “pantalla lenta” suele ser una sentencia SQL con un WHERE y ORDER BY muy específicos. Cópiala de logs, del output de debug de tu ORM o de la captura de consultas que ya uses.
Un flujo de trabajo que funciona en apps reales:
EXPLAIN (ANALYZE, BUFFERS) sobre la misma consulta.=, >=, LIKE, @>, @@), no solo en los nombres de columnas.EXPLAIN (ANALYZE, BUFFERS) con volumen de datos realista.Aquí tienes un ejemplo concreto. Una página de Customers filtra por tenant y status, ordena por lo más nuevo y pagina:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Si EXPLAIN muestra un escaneo secuencial y una ordenación, un índice B-tree que coincida con el filtro y el orden suele arreglarlo:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Si la parte lenta es un filtrado JSONB como metadata @> '{"plan":"pro"}', eso apunta a GIN. Si es búsqueda de texto completo como to_tsvector(...) @@ plainto_tsquery(...), eso también apunta a un índice de búsqueda respaldado por GIN. Si son operadores de “coincidencia más cercana” u overlap, ahí GiST suele encajar.
Después de añadir el índice, mide el intercambio. Comprueba el tamaño del índice, el tiempo de inserción y actualización, y si ayuda a las consultas lentas principales o solo a un caso extremo. En proyectos que avanzan rápido (incluidos los generados con Koder.ai), esta comprobación te ayuda a evitar acumular índices sin uso.
La mayoría de problemas con índices no son elegir B-tree vs GIN vs GiST. Son construir un índice que parece correcto pero no coincide con cómo la app consulta la tabla.
Errores que suelen perjudicar más:
tenant_id y created_at, pero el índice empieza con created_at, el planner puede descartarlo.status, is_active o un booleano a menudo ayuda poco porque coincide con demasiadas filas. Combínalo con una columna selectiva (como tenant_id o created_at) o sáltatelo.ANALYZE no se ha ejecutado recientemente, el planner puede elegir planes malos incluso cuando existe el índice correcto.Un ejemplo concreto: tu pantalla de Invoices filtra por tenant_id y status, luego ordena por created_at DESC. Un índice solo en status ayudará muy poco. Un ajuste mejor es un índice compuesto que empiece con tenant_id, luego status, luego created_at (filtrado primero, orden al final). Ese cambio suele vencer a añadir tres índices separados.
Trata cada índice como un coste. Tiene que ganarse su puesto con consultas reales, no solo en teoría.
Los cambios de índice son fáciles de enviar y molestos de deshacer si aumentan el coste de escritura o bloquean una tabla ocupada. Antes de hacer merge, trátalo como una pequeña release.
Empieza decidiendo qué optimizas. Extrae dos rankings cortos de tus logs o monitorización: las consultas que se ejecutan con más frecuencia, y las consultas con mayor latencia. Para cada una, escribe la forma exacta: columnas de filtrado, orden, joins y operadores usados (equals, range, IN, ILIKE, operadores JSONB, array contains). Esto evita adivinar y te ayuda a elegir el tipo de índice correcto.
Checklist previo al despliegue:
Tras añadir el índice, verifica que ayudó en el plan real. Ejecuta EXPLAIN (ANALYZE, BUFFERS) sobre la consulta exacta y compara antes y después. Luego vigila el comportamiento en producción durante un día:
Si construyes con Koder.ai, vale la pena mantener el SQL generado para una o dos pantallas lentas junto al cambio, de modo que el índice coincida con lo que la app realmente ejecuta.
Imagina una pantalla administrativa común: una lista de Users con scope por tenant, unos pocos filtros, orden por última actividad y un cuadro de búsqueda. Aquí los índices dejan de ser teoría y empiezan a ahorrar tiempo real.
Tres formas de consulta que verás normalmente:
-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;
-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;
-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
AND metadata @> '{"plan":"pro"}'::jsonb;
Un conjunto de índices pequeño pero intencional para esta pantalla:
(tenant_id, status, last_active_at DESC).tsvector generada con un índice GIN.GIN (metadata) cuando usas mucho @>, o un índice de expresión B-tree como ((metadata->>'plan')) cuando filtras mayormente por una clave.Las necesidades mixtas son normales. Si una página hace filtros + búsqueda + JSON, evita meterlo todo en un mega-índice. Mantén el B-tree para orden/paginación y añade un índice especializado (a menudo GIN) para la parte más cara.
Siguientes pasos: elige una pantalla lenta, anota sus 2-3 formas de consulta principales y revisa cada índice por propósito (filtro, orden, búsqueda, JSON). Si un índice no coincide claramente con una consulta real, elimínalo del plan. Si iteras rápido con Koder.ai, hacer esta revisión a medida que añades pantallas puede prevenir la proliferación de índices mientras tu esquema cambia.
Un índice permite a PostgreSQL encontrar filas coincidentes sin leer la mayor parte de la tabla. Para pantallas comunes de SaaS como listas, dashboards y búsqueda, el índice correcto puede convertir un escaneo secuencial lento en una búsqueda rápida que escala mejor conforme la tabla crece.
Empieza con B-tree para la mayoría de las consultas de la aplicación: es ideal para filtros con =, filtros por rango, joins y ORDER BY. Si la consulta trata principalmente sobre contención (JSONB, arrays) o búsqueda de texto, GIN suele ser la siguiente opción; GiST encaja más con consultas de solapamiento o “más cercano”.
Pon primero las columnas que filtras con =, y al final la columna por la que ordenas. Ese orden permite que el planner recorra el índice de forma eficiente, filtrando y devolviendo filas en el orden correcto sin necesitar una ordenación adicional.
Si cada consulta está delimitada por tenant_id, poner tenant_id primero agrupa las filas de cada tenant dentro del índice. Eso reduce la cantidad de datos de índice y tabla que PostgreSQL tiene que tocar para las vistas y listas diarias.
INCLUDE te permite añadir columnas extra para lecturas sólo por índice en páginas de lista sin ensanchar la clave del índice. Es útil cuando filtras y ordenas por unas pocas columnas pero también muestras otros campos en la pantalla.
Usa un índice parcial cuando sólo te importa un subconjunto de filas, por ejemplo “no eliminadas” o “solo activas”. Mantiene el índice más pequeño y barato de mantener, lo cual importa en tablas con muchas inserciones y actualizaciones.
Aplica un índice GIN en la columna JSONB cuando consultas frecuentemente con contención como metadata @> '{"plan":"pro"}'. Si filtras mayoritariamente por una o dos claves específicas del JSON, un índice de expresión B-tree en (metadata->>'plan') suele ser más pequeño y rápido.
GIN encaja bien cuando la pregunta principal es “¿contiene este array X?” usando operadores como @> o &&. Si necesitas metadato por elemento, actualizaciones frecuentes o analíticas por etiqueta/rol, una tabla de relación (join table) suele ser más fácil de mantener e indexar correctamente.
Para búsqueda de texto completa, almacena un tsvector (a menudo como columna generada) e indexalo con GIN; busca con @@ para resultados ordenados por relevancia. Para coincidencias difusas tipo ILIKE '%nombre%' y tolerancia a errores tipográficos, los trigramas (normalmente GIN) son la herramienta adecuada.
Copia el SQL exacto que ejecuta tu app y ejecuta EXPLAIN (ANALYZE, BUFFERS) para ver dónde se gasta el tiempo y si estás haciendo escaneos o ordenaciones. Añade el índice más pequeño que coincida con los operadores y el orden de la consulta, y vuelve a ejecutar el mismo EXPLAIN para confirmar que se usa y mejora el plan.