Multi-tenancy decisions compound. Pick the wrong isolation model, and migration to a stricter one is months of work. Pick too strict, and ops costs are 10× what they need to be. This post is the working tradeoff guide.
The three models
1. Shared DB, shared tables, tenant_id column
CREATE TABLE users (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
email text NOT NULL,
UNIQUE (tenant_id, email)
);
CREATE INDEX ON users (tenant_id);
Every query: WHERE tenant_id = $1.
Pros: Cheapest. Easiest to develop. Most flexible. Cons: Bug → cross-tenant leak. No physical isolation.
2. Shared DB, schema per tenant
CREATE SCHEMA tenant_42;
CREATE TABLE tenant_42.users (...);
-- App connects with search_path
SET search_path TO tenant_42, public;
Each tenant: its own schema.
Pros: Stronger isolation. Easier per-tenant backup / migration. Cons: Schema migrations × N tenants. Connection pool complexity.
3. Database per tenant
Each tenant: its own DB instance or its own database within a cluster.
Pros: Strongest isolation. Full per-tenant control. Compliance-friendly. Cons: Most expensive. Operational complexity grows linearly.
When each fits
| Use when | |
|---|---|
| Shared with tenant_id | <1000 tenants, B2C-ish, cost-sensitive |
| Schema-per-tenant | 100-1000 tenants needing isolation, mid-sized B2B |
| DB-per-tenant | <100 tenants, enterprise, compliance / data residency |
Most SaaS companies start with shared-with-tenant-id and never graduate.
Row-level security (RLS)
For shared-DB, RLS adds a safety net:
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::bigint);
# Per-request: set the tenant_id
async with pool.acquire() as conn:
await conn.execute("SET app.tenant_id = $1", tenant_id)
rows = await conn.fetch("SELECT * FROM users") # auto-filtered
Even if your app forgets WHERE tenant_id = $1, RLS prevents leak. Critical defense.
RLS gotchas
- Superuser bypasses RLS. Don’t connect as postgres / RDS master from the app.
BYPASSRLSrole attribute also bypasses. Avoid for app roles.FORCE ROW LEVEL SECURITYapplies RLS even to the table owner.- Test bypass scenarios in CI — write a test that queries as a different tenant and confirms 0 rows.
Query patterns
# Always scope tenant
async def list_users(tenant_id):
return await db.fetch("SELECT * FROM users WHERE tenant_id = $1", tenant_id)
# Helper if you must use raw queries
class TenantDB:
def __init__(self, db, tenant_id):
self.db = db
self.tenant_id = tenant_id
async def fetch(self, sql, *args):
# Prepend tenant filter; or rely on RLS
return await self.db.fetch(sql, *args, self.tenant_id)
The discipline: tenant_id is part of every key. Composite primary keys in some cases.
Cross-tenant analytics
Sometimes you need cross-tenant data (admin dashboards, billing).
# Use a "system" connection that bypasses RLS
async with system_pool.acquire() as conn:
total = await conn.fetchval("SELECT COUNT(*) FROM users")
Separate connection pool with elevated privileges, used only by admin paths. Audit every use.
Schema-per-tenant patterns
async def get_db_for_tenant(tenant_id):
schema = f"tenant_{tenant_id}"
pool = pools.get(tenant_id) or await create_pool(schema=schema)
return pool
Cache pools per tenant; lazy-create.
Migrations:
async def migrate_all_tenants():
schemas = await list_tenant_schemas()
for schema in schemas:
await alembic.upgrade(schema=schema)
100 tenants × migration time. Plan for it.
Per-tenant DB
async def get_db(tenant_id):
creds = await secrets.get(f"tenant_db/{tenant_id}")
return await asyncpg.create_pool(creds.dsn)
Each tenant’s connection string fetched at runtime.
For enterprise customers wanting their data in their region / cloud account: DB-per-tenant is sometimes the only option.
Tenant lookup
# Subdomain
acme.myapp.com → tenant 'acme'
# Path
myapp.com/acme/dashboard → tenant 'acme'
# Header
X-Tenant: acme
# JWT claim
{"tenant_id": "acme"}
JWT claim is most secure (signed, not user-controlled). Subdomain works for vanity URLs. Path is simplest for API.
Resource limits per tenant
ALTER ROLE tenant_42 SET statement_timeout = '5s';
ALTER ROLE tenant_42 SET work_mem = '32MB';
Per-tenant query limits, work memory. Prevents one tenant from starving others.
For shared-DB-with-RLS: harder. App-level rate limiting / query budgets needed.
Backup / restore per tenant
- Shared-DB: dump the whole DB; filtering on restore is messy. Logical replication for big-ticket tenants.
- Schema-per-tenant:
pg_dump --schema=tenant_42is clean. - DB-per-tenant: standard pg_dump per DB.
For “customer wants their data exported”: schema-per-tenant or DB-per-tenant make this easy.
Migration strategies
Going from shared-DB → schema-per-tenant or DB-per-tenant is a project. Plan:
- New writes go to new model.
- Backfill existing tenants in batches.
- Cutover read as each tenant catches up.
- Drop old after validation.
Months of work for big systems. Choose model wisely up front.
Common mistakes
1. Forgot tenant_id in a query
Cross-tenant leak. RLS catches it; without RLS, you find out from the customer. Always RLS for shared-DB.
2. Application-level filtering only
Bug → leak. RLS is the safety net.
3. Schema-per-tenant + 10000 tenants
Postgres struggles with thousands of schemas (catalog bloat). Stay <1000.
4. DB-per-tenant for everyone
Each tenant pays for a DB instance even if they make 10 calls/day. Massive waste.
5. No tenant ID in logs
Debugging cross-tenant issues impossible. Always include tenant_id in structured logs.
What I’d ship today
For a new B2B SaaS:
- Shared DB + tenant_id + RLS.
- JWT-based tenant identification.
- Per-tenant rate limits at the API gateway.
- Audit log of every tenant_id-scoped operation.
- CI test for cross-tenant access.
- Schema-per-tenant if you have <100 enterprise customers needing strict isolation.
- DB-per-tenant only when a customer’s contract requires it.
Read this next
If you want my multi-tenant Postgres template (RLS + tests), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .