Multi-Tenant Database Design Cheat Sheet
Multi-tenant database architecture patterns covering shared-schema with tenant_id, schema-per-tenant, database-per-tenant, and RLS.
Schema-per-Tenant Provisioning
Stronger isolation via a dedicated schema, with shared connection pooling.
-- Provisioning a new tenantCREATE SCHEMA tenant_acme;SET search_path TO tenant_acme;CREATE TABLE invoices (LIKE public.invoices_template INCLUDING ALL);-- Application connects and sets search_path per request-- e.g. in Node: await client.query('SET search_path TO tenant_acme');-- Migrations must be looped across all tenant schemas-- for schema in $(psql -tAc "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant_%'"); do# psql -c "SET search_path TO $schema; \i migration.sql"# done
Application-Layer Tenant Scoping (Prisma middleware example)
Defense-in-depth: enforce tenant scoping in the ORM even when RLS is also in place.
prisma.$use(async (params, next) => { const tenantId = getCurrentTenantId(); // from request context / AsyncLocalStorage if (params.model === 'Invoice') { if (params.action === 'findMany' || params.action === 'findFirst') { params.args.where = { ...params.args.where, tenantId }; } if (params.action === 'create') { params.args.data.tenantId = tenantId; } } return next(params);});
Isolation Models — Tradeoffs
The three standard multi-tenant data isolation strategies.
- Shared schema + tenant_id- cheapest to operate, easiest to scale, weakest isolation; needs RLS or app-layer enforcement
- Schema-per-tenant- stronger isolation, easier per-tenant backup/restore, but migrations and connection pooling get harder past hundreds of tenants
- Database-per-tenant- strongest isolation and blast-radius containment, best for compliance-heavy tenants, but highest operational overhead
- Row-Level Security (RLS)- Postgres feature that transparently filters rows by policy; defense-in-depth against a missed WHERE clause
- Noisy neighbor- one tenant's load degrading others' performance; mitigated by resource quotas or moving big tenants to dedicated DBs
- Tenant sharding- hybrid: group tenants across multiple shared-schema databases based on size/tier
RLS Driven by JWT Claims (Supabase/Postgres pattern)
Bind the RLS policy to claims extracted from a signed JWT instead of a manually SET session variable, so isolation holds even if app code forgets to set it.
CREATE OR REPLACE FUNCTION auth.tenant_id() RETURNS UUID AS $$ SELECT NULLIF(current_setting('request.jwt.claims', true)::json->>'tenant_id', '')::UUID;$$ LANGUAGE sql STABLE;CREATE POLICY tenant_isolation_select ON invoices FOR SELECT USING (tenant_id = auth.tenant_id());CREATE POLICY tenant_isolation_insert ON invoices FOR INSERT WITH CHECK (tenant_id = auth.tenant_id());-- Force RLS even for the table owner role (critical: owners bypass RLS by default)ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
Tenant Context in a Pooled Connection (PgBouncer transaction mode)
SET LOCAL scopes the tenant GUC to the current transaction only, which is mandatory under transaction-mode pooling where connections are shared across tenants between transactions.
async function withTenant<T>(tenantId: string, fn: (tx: PoolClient) => Promise<T>): Promise<T> { const client = await pool.connect(); try { await client.query('BEGIN'); // SET LOCAL, not SET — it auto-resets at COMMIT/ROLLBACK, safe to reuse the connection await client.query('SET LOCAL app.current_tenant = $1', [tenantId]); const result = await fn(client); await client.query('COMMIT'); return result; } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); }}
Composite Foreign Keys to Prevent Cross-Tenant Joins
Make tenant_id part of every primary key and foreign key so the database itself rejects a row referencing another tenant's parent — RLS alone doesn't stop this at the FK-constraint level.
CREATE TABLE accounts ( id UUID DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, name TEXT NOT NULL, PRIMARY KEY (tenant_id, id));CREATE TABLE invoices ( id UUID DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, account_id UUID NOT NULL, amount NUMERIC NOT NULL, PRIMARY KEY (tenant_id, id), -- Composite FK forces account_id to belong to the SAME tenant_id FOREIGN KEY (tenant_id, account_id) REFERENCES accounts (tenant_id, id));
Fan-Out Migrations Across Database-per-Tenant
Concurrent, capped-parallelism migration runner for the database-per-tenant model, with per-tenant failure isolation.
import asyncioimport asyncpgasync def migrate_tenant(dsn: str, sql: str, sem: asyncio.Semaphore) -> tuple[str, Exception | None]: async with sem: conn = await asyncpg.connect(dsn) try: async with conn.transaction(): await conn.execute(sql) return dsn, None except Exception as e: return dsn, e finally: await conn.close()async def migrate_all(tenant_dsns: list[str], sql: str, max_concurrency: int = 10): sem = asyncio.Semaphore(max_concurrency) results = await asyncio.gather(*(migrate_tenant(d, sql, sem) for d in tenant_dsns)) failed = [(dsn, err) for dsn, err in results if err] if failed: raise RuntimeError(f"{len(failed)} tenant migrations failed: {failed}")
Advanced Isolation Pitfalls
Failure modes that show up only after the basic RLS/schema setup is already in production.
- RLS + BYPASSRLS rolesbackground jobs and superuser connections silently bypass every policy unless FORCE ROW LEVEL SECURITY is set on the table
- Leaky query plansPostgres can push a non-tenant-scoped filter below the RLS check in rare planner cases; always verify with EXPLAIN that tenant_id is applied first
- Sequence/identity collisionsshared auto-increment sequences across schema-per-tenant clones can leak tenant cardinality; prefer UUIDs or per-tenant sequences
- Cross-tenant analyticsreporting queries that need to aggregate across all tenants require a separate role with RLS bypass, audited and never exposed to app traffic
- Backup/restore blast radiusin shared-schema mode, a point-in-time restore for one tenant's bad migration restores every tenant; database-per-tenant avoids this at the cost of ops overhead
- Tenant deprovisioningDELETE ... WHERE tenant_id = ? on large shared tables causes long-running locks; use batched deletes or partition-by-tenant with DROP PARTITION
- Connection pooling limitsdatabase-per-tenant multiplies connection pool count; a pooler-per-tenant-group (e.g. PgBouncer per shard) is needed past a few hundred tenants
Enforce tenant isolation at two layers, not one — RLS policies in Postgres plus tenant scoping in your ORM/query layer — because a single forgotten `WHERE tenant_id = ?` in application code is the single most common cause of real-world cross-tenant data leaks.