Mi base de datos era "correcta". También era 296 veces demasiado lenta.
Descubrí que mi base de datos Postgres tenía 89 claves foráneas pero cero índices sobre ellas, convirtiendo consultas de milisegundos en pesadillas de 843 ms que casi arruinan mi lanzamiento alfa.
25 de octubre de 2025. Mi aplicación SaaS por fin estaba completa en cuanto a funcionalidades. Nueve características principales, arquitectura multi-tenant, aprendizaje progresivo — todo funcionaba. Pero era lenta. Muy lenta.
Las consultas del dashboard se arrastraban. Las listas que deberían cargar al instante tardaban varios segundos. Los alpha testers empezaban a preguntar: "¿El sitio está caído?"
No solo me preocupaba el rendimiento. Me preocupaba sobrevivir.
Pasé dos días optimizando consultas, reescribiendo políticas RLS, añadiendo caché. Nada marcaba la diferencia. La frustración iba en aumento — a 5 días del lanzamiento alfa planificado y no conseguía entender por qué una base de datos técnicamente correcta rendía tan mal.
Entonces ejecuté una consulta de diagnóstico que me revolvió el estómago.
89 claves foráneas. Cero índices.
Esta es la historia de una "característica" de Postgres que nadie te cuenta — las 2 semanas que casi matan mi lanzamiento alfa — y la solución de 4 minutos que lo salvó todo.
Lo que estaba en juego: cuando lento significa muerto
Las investigaciones muestran que cada 100 ms de latencia cuesta un 1% de las conversiones. Con tiempos de carga de 2 a 3 segundos, no solo ofrecía una mala experiencia — estaba destruyendo las primeras impresiones que definirían el éxito o fracaso de mi alfa.
Lo que estaba en juego de inmediato:
- Alpha testers cuestionando si la plataforma era lo suficientemente estable como para recomendarla
- Pérdida de credibilidad con los primeros usuarios que me habían dado su confianza
- Si esto persistía hasta el lanzamiento completo: la investigación del sector sugiere una tasa de abandono del 40% con tiempos de carga de 3 segundos
- La diferencia entre "esto es prometedor" y "esto parece roto"
- CPU de la base de datos al 80-90% (caro y empeorando)
- A 5 días del lanzamiento planificado, completamente bloqueado
Lo que realmente costaron esas 2 semanas:
- Más de 40 horas depurando consultas lentas, perfilando código, culpando a las políticas RLS
- 1 semana de retraso en el lanzamiento (incumplí mi plazo interno)
- Oportunidad perdida de estar construyendo funcionalidades
- Daño a la credibilidad con los primeros testers
La deuda técnica se estaba convirtiendo en deuda de negocio. Y no sabía por qué.
---
La investigación: siguiendo las consultas lentas
A finales de octubre, mi plataforma tenía complejidad real: más de 30 tablas, más de 80 políticas RLS, cientos de relaciones de claves foráneas. Operaciones simples que deberían ser instantáneas eran dolorosamente lentas — cargar listas, obtener datos del dashboard, filtrar por org_id.
Empecé con pg_stat_statements para encontrar a los culpables:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```
Cada consulta lenta tenía el mismo patrón — filtrado por claves foráneas:
```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```
Debería ser instantáneo. Tenía esas columnas. Tenía políticas RLS. ¿Por qué era lento?
Revisé el plan de ejecución:
```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```
El resultado me hundió el ánimo:
```
Seq Scan on user_data (cost=0.00..2847.23 rows=15 width=1024)
Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```
Escaneo secuencial. Postgres leía cada fila de la tabla, luego filtraba. En una consulta que debería tardar milisegundos.
Pero tenía claves foráneas en org_id y resource_id. ¿No deberían estar indexadas?
---
La revelación: las claves foráneas no se indexan automáticamente
Esto es lo que aprendí ese día, de la manera más dura:
Postgres crea automáticamente índices para PRIMARY KEYs y restricciones UNIQUE.
Postgres NO crea automáticamente índices para las claves foráneas.
Déjame repetirlo, porque este único malentendido me costó 2 semanas de mi vida:
FOREIGN KEYS ≠ INDEXES
Cuando escribes esto:
```sql
CREATE TABLE user_data (
id UUID PRIMARY KEY, -- ✅ Indexado automáticamente
org_id UUID REFERENCES organizations(id), -- ❌ ¡NO indexado!
resource_id UUID REFERENCES resources(id), -- ❌ ¡NO indexado!
created_at TIMESTAMPTZ
);
```
Postgres crea la restricción de clave foránea (integridad referencial), pero **no** crea un índice en org_id ni en resource_id.
¿Por qué? Porque Postgres no puede asumir cómo vas a consultar los datos. Quizás nunca filtras por claves foráneas. Quizás siempre haces joins en una dirección específica. Así que te deja la decisión a ti.
¿El problema? No sabía que necesitaba tomar esa decisión. Asumí que "clave foránea" significaba "indexada para consultas". No es así.
---
El diagnóstico: ¿cuán grave era?
Escribí una consulta para encontrar cada clave foránea sin índice:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
El resultado me golpeó como un tren de carga:
89 filas.
89 claves foráneas en 32 tablas. Cero índices.
Cada join, cada filtro, cada verificación de política RLS hacía escaneos completos de tabla. No era de extrañar que todo fuera lento.
---
La solución: el Apocalipsis de los Índices
25 de octubre de 2025. 7:51 AM. Sabía lo que tenía que hacer.
Una migración. 89 índices. Lo llamé "El Apocalipsis de los Índices".
```sql
-- La Migración del Apocalipsis de los Índices
-- Propósito: Corregir el rendimiento indexando todas las columnas de clave foránea
-- Tablas con scope de organización
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);
-- Tablas con scope de recurso
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);
-- Índices multi-columna para patrones de consulta comunes
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);
-- Consultas de borrado suave
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);
-- Total: 89 índices en 32 tablas
```
Ejecuté la migración. Mi mano literalmente se detuvo sobre la tecla Enter por un momento, preguntándome si estaba a punto de empeorar las cosas. T.T
Tiempo de migración: 4 minutos.
---
Los resultados: de desastre a listo para el lanzamiento
Antes (sin índices)
```sql
Seq Scan on user_data
Execution Time: 843.271 ms
Rows Removed by Filter: 12,834
```
Después (con índices)
```sql
Index Scan using idx_user_data_org_resource
Execution Time: 2.847 ms
```
843 ms → 2,8 ms
Eso es 296 veces más rápido para una sola consulta.
Impacto en el mundo real:
- Carga del dashboard: 2-3 segundos → 120 ms
- Listas de recursos: más de 1 segundo → 45 ms
- Consultas de datos: 850 ms → 12 ms
Mejora media: 20-40 veces más rápido. ¿Algunos joins complejos con múltiples claves foráneas? 100 veces más rápido.
La diferencia fue como el día y la noche. La plataforma pasó de "¿Esto está roto?" a "Vaya, qué rápido esto." :D
---
Por qué esto importa para el SaaS multi-tenant
Si estás construyendo SaaS multi-tenant con Row-Level Security, esto es absolutamente crítico.
Las políticas RLS se ejecutan en cada consulta:
```sql
CREATE POLICY resources_org_isolation ON resources
USING (org_id = get_user_org_id());
```
Sin un índice en org_id, esta política obliga a un escaneo secuencial en cada consulta. Incluso las sentencias SELECT simples se vuelven dolorosamente lentas.
El coste: Aislamiento multi-tenant sin índices = infraestructura cara + experiencia de usuario lenta = abandono.
La lección de negocio: Las funciones de seguridad sin optimización de rendimiento no son realmente seguras — porque los usuarios se irán antes de que experimenten tu seguridad.
---
El patrón: qué indexar
Después de esta costosa lección, esta es mi lista de verificación para cada nueva tabla:
Indexa siempre estos:
1. Columnas de clave foránea — Cada una de ellas
2. Columnas en políticas RLS — Especialmente org_id en aplicaciones multi-tenant
3. Columnas en cláusulas WHERE — Si filtras por ella frecuentemente, indexa
4. Columnas en ORDER BY — Ordenar sin índices destruye el rendimiento
Considera índices multi-columna:
```sql
-- Para: WHERE org_id = X AND resource_id = Y
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```
No sobre-indexes: Cada índice tiene un coste en almacenamiento, rendimiento de escritura y mantenimiento. Regla general: si aparece frecuentemente en WHERE/JOIN/ORDER BY, indexa. De lo contrario, no.
---
Cómo revisar tu base de datos ahora mismo
Ejecuta esta consulta de diagnóstico:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
Si obtienes resultados, tienes índices que faltan.
Luego encuentra tus consultas lentas:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;
```
Usa EXPLAIN ANALYZE en las lentas para verificar que estás viendo escaneos secuenciales.
---
Lecciones aprendidas
1. Las suposiciones sobre Postgres son peligrosas
Asumí que las claves foráneas estaban indexadas. No lo están. Verifica siempre.
2. RLS necesita índices
La Row-Level Security es inútil — de hecho, peor que inútil — sin índices adecuados. Añade sobrecarga a cada consulta.
3. Multi-tenant = indexa org_id en todas partes
En arquitectura multi-tenant, org_id aparece en casi todas las consultas. Indexa en cada tabla. Sin excepciones.
4. El rendimiento es un problema de negocio
A los usuarios no les importa que tu SQL sea técnicamente correcto si la página tarda 3 segundos en cargar. Consultas rápidas = mejor conversión = ingresos.
5. Añade índices temprano
Añadir índices a tablas vacías es instantáneo. Añadirlos a tablas con millones de filas lleva horas y bloquea la tabla. Hazlo durante el desarrollo inicial.
6. Mide todo
Usa EXPLAIN ANALYZE antes y después. Demuestra la mejora con datos.
---
Tu lista de verificación
Para cada nueva tabla que crees:
```sql
CREATE TABLE new_table (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
resource_id UUID REFERENCES resources(id),
created_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- ✅ Indexa las claves foráneas
CREATE INDEX idx_new_table_org_id ON new_table(org_id);
CREATE INDEX idx_new_table_resource_id ON new_table(resource_id);
-- ✅ Indexa columnas RLS + filtros comunes
CREATE INDEX idx_new_table_org_archived ON new_table(org_id, archived_at);
-- ✅ Indexa columnas de ordenación
CREATE INDEX idx_new_table_created ON new_table(created_at DESC);
-- ✅ Crea políticas RLS
CREATE POLICY new_table_org_isolation ON new_table
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
---
Reflexiones finales
89 índices que faltaban. 2 semanas de depuración. Una consulta de diagnóstico para encontrarlos todos. 4 minutos para solucionarlo.
¿La ironía? Postgres te da todas las herramientas para diagnosticar esto. Simplemente no sabía dónde mirar. Ahora cada tabla que creo se indexa de inmediato — claves foráneas, columnas RLS, campos de ordenación, todo.
La migración de 4 minutos que añadió 89 índices ahorró semanas de trabajo de optimización futuro y evitó un desastre en el lanzamiento.
Para fundadores: El rendimiento no es solo un problema de ingeniería. Es un problema de conversión, un problema de retención y un problema de credibilidad. Esa base de datos "técnicamente correcta" que es demasiado lenta para usar, ¿te está costando clientes? Invierte en rendimiento desde el principio.
Para ingenieros: Postgres no va a auto-indexar tus claves foráneas. Pero tú deberías. Tus usuarios (y la CPU de tu base de datos) te lo agradecerán.
¿Has tenido alguna vez un momento de "técnicamente correcto pero dolorosamente lento" con tu base de datos? ¿Cuál fue la solución — y cuánto tardaste en encontrarla?
Un abrazo,
Chandler
La serie de arquitectura STRAŦUM: Esta crisis de rendimiento fue la pieza final de un rompecabezas de multi-tenancy que comenzó con construir multi-tenancy el Día 2, continuó con una reconstrucción completa del esquema el Día 67, e incluyó arreglar 31 pantallas en blanco por pérdida del contexto de navegación.
---
¿Construyendo SaaS multi-tenant con Postgres? Aprendí esta lección de la manera más dura para que tú no tengas que hacerlo. Revisa tu base de datos ahora antes de que tus usuarios lo noten.
Solicita acceso alfa en https://stratum.chandlernguyen.com/request-invitation
---
Aún aprendiendo que "funciona" y "funciona rápido" son objetivos distintos — y solo uno de ellos se lanza.
---





