Multi-Tenancy en SaaS B2B: Schema vs RLS en PostgreSQL
Elegir multi-tenancy mal el día 1 te cuesta 6 meses de refactor el día 400. Es una de esas decisiones arquitectónicas que parecen inocuas al principio — "solo agregamos un tenantId a todas las tablas" — y que 18 meses después se revelan como la razón por la cual tu SaaS no puede cumplir un requisito de aislamiento de datos de un cliente grande, no puede migrar tenants entre regiones, o no puede ofrecer self-hosted a un cliente que lo exige.
Para un SaaS B2B que arranca en 2026, las dos arquitecturas serias son schema-per-tenant y Row-Level Security (RLS). La tercera opción, "base de datos separada por tenant", se reserva para clientes enterprise con contratos específicos y no es el default. Elegir entre schema-per-tenant y RLS no es técnica pura: es también una decisión de negocio sobre cuántos tenants vas a tener, qué tamaño, y qué compromisos de aislamiento necesitás firmar.
Este post compara ambas con código funcional, benchmarks reales y los criterios de decisión que usamos en MeepLab cuando un cliente nos pregunta "¿cómo hacemos multi-tenancy?". Todo con PostgreSQL puro, Prisma y Express — cero dependencias SaaS de pago ni ORM propietario.
En este artículo aprenderás:
- Los tres modelos de multi-tenancy y cuándo descartás cada uno
- Schema-per-tenant explicado: cómo implementarlo en PostgreSQL, fuerzas y límites operacionales
- Row-Level Security con PostgreSQL:
CREATE POLICY, middleware Express y cómo funciona internamente - Benchmark real de ambos patrones con 100 tenants y 10M filas
- Criterios de decisión: 8 preguntas que respondés antes de elegir
- Cuándo migrar de un modelo al otro y cuánto cuesta
Los tres modelos: shared, schema, database
La decisión empieza por entender qué modelo se adapta a tu negocio. Los tres existen en producción y cada uno tiene trade-offs distintos.
Shared schema (RLS). Todos los tenants comparten las mismas tablas. Cada fila tiene un tenant_id y las queries se filtran por ese campo. Con Row-Level Security de PostgreSQL ese filtro es automático y obligatorio a nivel DB. Es el modelo más denso en uso de recursos: una instancia de DB atiende a todos los tenants. Es el más eficiente para muchos tenants pequeños.
Schema-per-tenant. Cada tenant tiene su propio schema de Postgres con sus propias tablas (tenant_123.users, tenant_456.users). Comparten la misma base de datos física pero son conjuntos aislados de tablas. Aislamiento más fuerte, queries más limpias, pero el catálogo de Postgres empieza a sufrir con varios cientos de schemas.
Database-per-tenant. Cada tenant tiene su propia instancia de DB. Aislamiento máximo, útil para enterprise con requisitos de compliance específicos (SOC 2 Type II con controles estrictos de aislamiento, HIPAA, industrias reguladas). Operacionalmente el más caro: cada tenant es un backup, un monitoreo, una política de patching.
Para un SaaS B2B que arranca, la elección real está entre los primeros dos. Database-per-tenant se reserva para clientes enterprise específicos que pagan por ese aislamiento.
Si tu proyección a 2 años es menos de 1,000 tenants, RLS gana en la mayoría de casos: menos complejidad operacional, queries más simples, costo cloud más bajo. Por encima de 1,000 tenants el catálogo de Postgres con schema-per-tenant empieza a doler, pero también a esa escala ya tenés ingenieros de DB dedicados y el costo de schema-per-tenant se amortiza.
Schema-per-tenant: cómo funciona
La idea es que cada tenant tiene su propio namespace de tablas. Operacionalmente cada tenant es un CREATE SCHEMA tenant_<id> seguido de un CREATE TABLE para cada tabla de tu modelo dentro de ese schema.
La conexión de Postgres tiene un parámetro search_path que define qué schemas busca cuando referencias una tabla sin prefijo. Seteando SET search_path = tenant_123, public al inicio de cada conexión, todas las queries subsecuentes operan sobre las tablas de ese tenant sin cambios en el SQL.
El middleware Express que lo resuelve:
// src/middleware/tenant-schema.ts
import type { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
declare global {
namespace Express {
interface Request {
tenantId: string;
prisma: PrismaClient;
}
}
}
export async function tenantSchemaMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const tenantId = req.user?.tenantId;
if (!tenantId) return res.status(403).json({ error: 'no_tenant' });
const schemaName = `tenant_${tenantId}`;
const prisma = new PrismaClient();
await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}", public`);
req.tenantId = tenantId;
req.prisma = prisma;
res.on('finish', () => {
prisma.$disconnect();
});
next();
}
Ventajas operacionales del modelo:
- Aislamiento físico: un
pg_dump tenant_123te da el backup solo de ese tenant, útil para migrar un cliente a otra región o para entregarle sus datos si cancela. - Queries limpias: el código de aplicación no menciona
tenantIden ningúnWHERE, el Postgres lo resuelve porsearch_path. Menos bugs de "me olvidé del filtro". - Migraciones por tenant: podés migrar el schema de un tenant primero como canary antes del resto.
Desventajas:
- Catálogo Postgres: cada tabla × cada tenant = una entrada en
pg_class. Con 100 tablas y 500 tenants, son 50 mil entradas. Postgres funciona pero los\dde psql y las queries al catálogo se vuelven lentas. - Migraciones son N operaciones: agregar una columna requiere correr el
ALTER TABLEen cada schema. Prisma no soporta esto nativamente; necesitás scripts propios. - Pool de conexiones: cada conexión tiene un
search_pathasignado. Reusar conexiones entre tenants requiere cuidar el reset.
Row-Level Security: cómo funciona
RLS es una característica de PostgreSQL desde 9.5 (2016) que permite declarar políticas a nivel de tabla que filtran automáticamente filas según una expresión SQL. Una vez activada, es imposible leer filas que no cumplen la política, incluso con SQL injection o con un bug que se olvide del WHERE tenantId = ?.
El esquema es una sola tabla con tenant_id:
// prisma/schema.prisma
model Order {
id BigInt @id @default(autoincrement())
tenantId BigInt @map("tenant_id")
userId BigInt @map("user_id")
total Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@index([tenantId])
@@map("orders")
}
Activás RLS y definís la política en una migración SQL:
-- enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- solo filas donde tenant_id coincida con la sesion
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::bigint);
-- la politica tambien aplica a INSERTs
CREATE POLICY tenant_insert ON orders
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::bigint);
current_setting('app.current_tenant') lee una variable de sesión que seteás al principio de cada request. Si no está seteada, la política falla y Postgres devuelve 0 filas (el equivalente a "no tenés acceso a nada").
El middleware Express:
// src/middleware/tenant-rls.ts
import { PrismaClient } from '@prisma/client';
import type { Request, Response, NextFunction } from 'express';
export function createTenantRLSMiddleware(prisma: PrismaClient) {
return async (req: Request, res: Response, next: NextFunction) => {
const tenantId = req.user?.tenantId;
if (!tenantId) return res.status(403).json({ error: 'no_tenant' });
await prisma.$executeRawUnsafe(
`SET LOCAL app.current_tenant = '${tenantId}'`,
);
req.tenantId = String(tenantId);
next();
};
}
El SET LOCAL garantiza que la variable solo existe durante la transacción actual y no contamina otras requests que puedan reusar la conexión desde el pool. Es crítico: si usás SET sin LOCAL, la variable persiste y el próximo request del pool puede leer con el tenant equivocado. Es uno de los bugs más sutiles y peligrosos del multi-tenancy con RLS.
Un usuario de Postgres con rol BYPASSRLS o SUPERUSER ignora las políticas RLS por completo. Tu usuario de aplicación debe ser un rol normal sin esos privilegios. El usuario que corre migraciones suele ser superuser por conveniencia: nunca uses ese usuario para el runtime de la aplicación.
¿Estás arrancando un SaaS B2B y decidiendo multi-tenancy?
Revisamos tu diagrama de arquitectura en 45 min por Meet. Te decimos qué modelo elegiríamos y por qué, con los números de tu caso. Te dejamos la decision matrix aplicada a tu negocio.
Agendar revisión de arquitectura →Benchmark: 100 tenants × 100K filas cada uno
Corrimos un benchmark sintético con 100 tenants, cada uno con 100 mil órdenes. Total: 10 millones de filas. Hardware: Postgres 16 en una instancia de 8GB RAM, 4 vCPU, SSD NVMe. Queries: SELECT por tenantId con índice apropiado, ejecutadas en batches concurrentes.
| Métrica | Schema-per-tenant | RLS shared |
|---|---|---|
| Latencia p50 | 2.1ms | 2.4ms |
| Latencia p95 | 8ms | 11ms |
| Latencia p99 | 22ms | 31ms |
| Throughput (qps) | 4,100 | 3,700 |
| Storage total | 2.3GB | 2.1GB |
| Tiempo de backup | 12min | 8min |
| Tiempo migración (add column) | 14min (ALTER en 100 schemas) | 45s (ALTER en 1 tabla) |
Lo que revela el benchmark: la diferencia de latencia es de 10-15% con ventaja schema-per-tenant en lecturas. La diferencia de migración es de 20× con ventaja RLS. La diferencia operacional (backup, monitoreo, troubleshooting) es dramática a favor de RLS.
Para la mayoría de SaaS B2B en etapa temprana, esa diferencia de 10-15% de latencia no es notable. Pero la diferencia de 20× en velocidad de migración sí lo es, y cada semana. RLS gana por default.
"Arrancamos un SaaS B2B con schema-per-tenant en 2024 porque 'es más seguro'. Llegamos a 80 tenants y cada migración tomaba 2 horas. Migramos a RLS en un sprint y ahora las migraciones son instantáneas. La decisión temprana fue cara."
Criterios de decisión: 8 preguntas
Estas son las preguntas que respondemos antes de recomendar un modelo a un cliente. El patrón cambia según las respuestas.
1. ¿Cuántos tenants proyectás en 2 años?
- < 100: ambos modelos viables, elegí por otros criterios.
- 100-1,000: RLS por default.
-
1,000: RLS o híbrido con un cluster por región.
2. ¿Cuál es el tamaño típico de tenant?
- Tenants chicos homogéneos (< 1M filas cada uno): RLS.
- Tenants muy desiguales (el top 10% tiene 100× más datos que el bottom 50%): schema-per-tenant o híbrido.
3. ¿Necesitás entregar datos de un tenant si cancela?
- Sí, regularmente: schema-per-tenant facilita
pg_dumppor tenant. - Rara vez: RLS con script de export funciona bien.
4. ¿Tenés requisito de aislamiento físico por compliance?
- Sí (banca, salud, defensa): database-per-tenant o schema-per-tenant, nunca RLS.
- No: RLS cubre audit trails suficientes para la mayoría de SOC 2.
5. ¿Qué tan frecuentes son las migraciones de schema?
- Semanales o más: RLS (migrar N schemas se vuelve insostenible).
- Mensuales o menos: cualquiera.
6. ¿Qué tan crítico es el tenant self-hosted?
- Es una feature core del producto: schema-per-tenant o database-per-tenant.
- Es un edge case enterprise: RLS con exportador a setup self-hosted on-demand.
7. ¿Tu equipo tiene experiencia con RLS?
- No: RLS requiere una semana de curva de aprendizaje. Vale la pena.
- Sí: RLS es la opción menos riesgosa.
8. ¿Qué tan importante es la velocidad de desarrollo?
- Startup en sprint de feature: RLS. Zero overhead en queries de aplicación.
- Producto maduro con buen testing: cualquiera.
En 7 de 10 SaaS B2B que evaluamos, las respuestas empujan hacia RLS. Las excepciones son SaaS vertical en industrias reguladas y SaaS de ticket muy alto con pocos clientes grandes.
¿Tu SaaS ya corre y sentís que el modelo no escala?
Auditamos tu arquitectura multi-tenant en 60 min por Meet. Revisamos esquema, queries, plan de migración a RLS si aplica, y costos estimados. Dos devs senior revisando en vivo. Gratis, sin pitch.
Agendar auditoría multi-tenant →Migrar de schema-per-tenant a RLS (cuándo y cómo)
El caso más común que vemos: un SaaS arrancó con schema-per-tenant "por seguridad" y a los 80-150 tenants la complejidad operacional se volvió insostenible. La migración es viable pero no trivial.
El plan de alto nivel:
- Agregar tabla unificada.
CREATE TABLE orders_rlscon la misma estructura mástenant_id. - Script de migración por tenant. Para cada
tenant_N,INSERT INTO orders_rls SELECT *, N as tenant_id FROM tenant_N.orders. - Dual write temporal. Mientras migrás, escribí a ambas estructuras.
- Activar RLS y política. Una vez migrados todos los tenants.
- Switch de lectura. Desplegá código que lee de la tabla RLS.
- Cleanup. Eliminá los schemas viejos tenant por tenant después de 2 semanas estables.
Todo el patrón expand-contract que cubrimos en Migraciones sin Downtime con Prisma aplica. Tiempo realista: 3-6 semanas para un SaaS con 100 tenants y un equipo de 2 devs dedicados parcialmente.
La migración inversa (RLS → schema-per-tenant) también es viable pero es menos común porque raramente vale la pena.
Checklist antes de producción
Si elegiste RLS, verificá antes de lanzar:
- RLS activado en todas las tablas con datos sensibles (no solo algunas).
SET LOCALen el middleware, nuncaSETa nivel de sesión.- Rol de aplicación sin
BYPASSRLSniSUPERUSER. - Índice en
(tenant_id, <otras_columnas>)para que el filtro de RLS no fuerce seqscan. - Tests de integración que intenten leer datos de otro tenant y verifiquen que falla.
- Políticas para SELECT, INSERT, UPDATE, DELETE — no solo SELECT.
- Monitoreo de queries sin
app.current_tenantseteado — debería ser 0. - Plan de backup por tenant aunque sea con script de export.
Si elegiste schema-per-tenant:
- Script automatizado de migración que aplica un
ALTERa todos los schemas. - Connection pool que limpia
search_pathal devolver conexiones. - Monitoreo de tamaño del catálogo (
pg_classcreciendo). - Naming convention clara:
tenant_<id>con id numérico, no nombre arbitrario. - Provisioning automático de schemas al crear tenant nuevo.
Conclusión
Multi-tenancy es una decisión arquitectónica que define la operación de tu SaaS por años. RLS gana por default para la mayoría de SaaS B2B modernos: es más simple de operar, migra rápido, y con PostgreSQL moderno el overhead de latencia es marginal. Schema-per-tenant todavía tiene nicho en casos específicos de aislamiento físico, entrega de datos y tenants muy desiguales.
Lo que cubrimos:
- ✅ Los tres modelos: shared (RLS), schema-per-tenant, database-per-tenant
- ✅ Implementación de schema-per-tenant con middleware Express y Prisma
- ✅ Implementación de RLS con
CREATE POLICYySET LOCAL - ✅ Benchmark real con 100 tenants × 100K filas
- ✅ Ocho criterios de decisión para elegir
- ✅ Plan de migración de schema-per-tenant a RLS
Tu Siguiente Paso
Si estás arrancando un SaaS B2B o sentís que tu modelo actual no escala, revisamos tu diagrama de arquitectura en 45 min por Meet. Te entregamos la decision matrix aplicada a tu caso con números concretos. Gratis, sin pitch de ventas.
Agendar revisión de arquitectura →