The Three Multi-Tenancy Models
Before diving into PostgreSQL specifics, understand the spectrum:
| Model |
Isolation Level |
Cost Efficiency |
Complexity |
Best For |
| Database-per-Tenant |
Highest (separate Postgres instances) |
Lowest |
Low |
Enterprise SaaS, regulated industries |
| Schema-per-Tenant |
High (separate namespaces) |
Medium |
Medium |
B2B SaaS with 100-10,000 tenants |
| Row-Level Security |
Medium (shared tables) |
Highest |
High |
B2C SaaS with 10,000+ tenants |
This article focuses on the middle two, as database-per-tenant is straightforward but expensive (separate connection pools, backups, monitoring per tenant).
Row-Level Security (Pool Model)
Architecture
All tenants share the same database and schema. Each table includes a tenant_id column, and PostgreSQL RLS policies enforce automatic filtering:
-- Enable RLS on a table
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
customer_name TEXT,
total DECIMAL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see rows matching their tenant_id
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- Set tenant context per connection
SET app.current_tenant = '123e4567-e89b-12d3-a456-426614174000';
-- This query automatically filters to tenant_id = '123e...'
SELECT * FROM orders;
Implementation with Connection Pooling
The challenge: connection pools share connections across requests. Each request must set app.current_tenant before queries:
const { Pool } = require('pg');
const pool = new Pool({
host: 'localhost',
database: 'saas_app',
max: 20
});
async function withTenantContext(tenantId, callback) {
const client = await pool.connect();
try {
// CRITICAL: Set tenant context
await client.query('SET LOCAL app.current_tenant = $1', [tenantId]);
// Run user queries
const result = await callback(client);
return result;
} finally {
client.release(); // Returns to pool, context resets
}
}
// Express middleware
app.use(async (req, res, next) => {
const tenantId = extractTenantFromToken(req.headers.authorization);
req.db = {
query: (text, params) => withTenantContext(tenantId, client =>
client.query(text, params)
)
};
next();
});
// Route handler - automatic tenant filtering
app.get('/orders', async (req, res) => {
const { rows } = await req.db.query('SELECT * FROM orders');
res.json(rows); // Only tenant's orders returned
});
Strengths:
- Single connection pool serves all tenants
- Lowest infrastructure cost (one database instance)
- Efficient for uniform tenant sizes and workloads
- Index sharing reduces storage overhead
Weaknesses:
- No query-level isolation metrics (can't track per-tenant resource usage)
- One massive table scan affects all tenants
- Index contention on high-write tenants impacts neighbors
- Difficult to move individual tenants to different databases
Security Risks
Critical failure mode: Forgetting SET app.current_tenant leaks all data.
// DANGEROUS: Missing tenant context
const client = await pool.connect();
const { rows } = await client.query('SELECT * FROM orders'); // Returns ALL tenants!
client.release();
// SAFE: Always use wrapper
const { rows } = await req.db.query('SELECT * FROM orders');
Mitigation strategies:
- Database-enforced defaults:
-- Deny access by default, require explicit tenant context
CREATE POLICY deny_by_default ON orders
USING (false);
-- Only allow access when tenant_id is set
CREATE POLICY allow_with_tenant ON orders
USING (
current_setting('app.current_tenant', true) IS NOT NULL
AND tenant_id = current_setting('app.current_tenant')::UUID
);
- Application-level guard:
pool.on('connect', client => {
// Prevent queries without tenant context
const originalQuery = client.query.bind(client);
client.query = function(...args) {
const tenantId = client._tenantId; // Set by withTenantContext
if (!tenantId && process.env.NODE_ENV === 'production') {
throw new Error('Query attempted without tenant context');
}
return originalQuery(...args);
};
});
- Audit logging:
-- Log all queries with tenant context
CREATE TABLE query_audit (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID,
query_text TEXT,
executed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Trigger on every query (expensive, use sparingly)
CREATE OR REPLACE FUNCTION log_query() RETURNS event_trigger AS $$
BEGIN
INSERT INTO query_audit (tenant_id, query_text)
VALUES (
current_setting('app.current_tenant', true)::UUID,
current_query()
);
END;
$$ LANGUAGE plpgsql;
Schema-per-Tenant (Bridge Model)
Architecture
Each tenant gets a separate PostgreSQL schema (namespace) within the same database:
-- Create schemas
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- Identical table structure in each schema
CREATE TABLE tenant_acme.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_name TEXT,
total DECIMAL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tenant_globex.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_name TEXT,
total DECIMAL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Set search path per connection
SET search_path TO tenant_acme;
-- Queries now automatically use tenant_acme.orders
SELECT * FROM orders;
Implementation with Dynamic Schema Routing
const { Pool } = require('pg');
// Single pool, schema-per-tenant
const pool = new Pool({
host: 'localhost',
database: 'saas_app',
max: 50 // Higher max for schema-per-tenant
});
async function withTenantSchema(tenantSlug, callback) {
const client = await pool.connect();
try {
// Set schema search path
await client.query(`SET search_path TO tenant_${tenantSlug}, public`);
const result = await callback(client);
return result;
} finally {
client.release();
}
}
// Tenant provisioning
async function provisionTenant(tenantSlug) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Create schema
await client.query(`CREATE SCHEMA IF NOT EXISTS tenant_${tenantSlug}`);
// Run migrations for this schema
await client.query(`SET search_path TO tenant_${tenantSlug}`);
await client.query(`
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_name TEXT,
total DECIMAL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Route handler
app.get('/orders', async (req, res) => {
const tenantSlug = extractTenantSlug(req.headers.host); // e.g., acme.saas.com
const { rows } = await withTenantSchema(tenantSlug, client =>
client.query('SELECT * FROM orders')
);
res.json(rows);
});
Strengths:
- Per-tenant query performance metrics (use pg_stat_statements filtered by schema)
- Tenant-specific index tuning and optimization
- Easy to move tenants to different databases (export schema)
- No risk of cross-tenant data leaks via missing WHERE clause
Weaknesses:
- More schemas = more memory overhead (each schema has metadata)
- Connection pool pressure (need higher max connections)
- Schema DDL changes require looping through all tenants
- PostgreSQL has no hard schema limit, but performance degrades beyond ~10,000 schemas
Migration Management
Challenge: Running ALTER TABLE on 5,000 schemas takes hours.
Solution: Background migration worker with progress tracking:
const tenants = await getTenantList(); // ['acme', 'globex', ...]
for (const tenant of tenants) {
await withTenantSchema(tenant, async client => {
try {
await client.query('BEGIN');
// Add column
await client.query(`
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS discount DECIMAL DEFAULT 0
`);
await client.query('COMMIT');
console.log(`Migrated tenant: ${tenant}`);
} catch (err) {
await client.query('ROLLBACK');
console.error(`Failed to migrate ${tenant}:`, err);
// Continue to next tenant
}
});
}
Optimization: Parallel migrations with concurrency control:
const pLimit = require('p-limit');
const limit = pLimit(10); // 10 concurrent migrations
const migrations = tenants.map(tenant =>
limit(() => withTenantSchema(tenant, migrateSchema))
);
await Promise.all(migrations);
Hybrid Approach: Best of Both Worlds
Many production SaaS apps use a hybrid model:
- Shared infrastructure tables (users, billing, audit logs) in public schema with RLS
- Tenant business data (orders, products, invoices) in tenant-specific schemas
Architecture
-- Shared infrastructure (RLS)
CREATE TABLE public.tenants (
id UUID PRIMARY KEY,
slug TEXT UNIQUE,
plan TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE public.users (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES public.tenants(id),
email TEXT,
role TEXT
);
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON public.users
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- Tenant data (schema-per-tenant)
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.orders (...);
Why This Works
- Shared tables (users, tenants) rarely change schema → RLS overhead acceptable
- Business tables (orders) have tenant-specific customizations → schema isolation essential
- Queries join across
public and tenant_* schemas seamlessly
SET search_path TO tenant_acme, public;
-- Joins work across schemas
SELECT
o.id,
o.total,
u.email AS created_by
FROM orders o
JOIN users u ON o.created_by_user_id = u.id;
Tested on PostgreSQL 16, AWS RDS db.m6g.xlarge (4 vCPU, 16 GB RAM):
| Model |
Inserts/sec |
Avg Latency |
p95 Latency |
CPU Usage |
| RLS (single table) |
18,500 |
3.2ms |
8.1ms |
42% |
| Schema-per-tenant |
16,200 |
3.8ms |
12.3ms |
38% |
Analysis: RLS slightly faster due to single B-tree index. Schema-per-tenant has 1,000 separate indexes (more overhead).
| Model |
Query Time |
Rows Scanned |
Index Usage |
| RLS |
280ms |
10M (all tenants) |
❌ Seq scan |
| Schema-per-tenant |
3ms |
10K (one tenant) |
✅ Index scan |
Analysis: Without proper indexing on tenant_id, RLS scans all rows. Schema isolation naturally limits scan scope.
| Model |
Query Time |
Rows Scanned |
Index Usage |
| RLS |
4.1ms |
10K (one tenant) |
✅ Index scan on tenant_id |
| Schema-per-tenant |
3.2ms |
10K (one tenant) |
✅ Index scan on PK |
Analysis: With proper indexing, performance is comparable. Schema-per-tenant has slight edge due to smaller index size.
| Model |
Total Time |
Downtime |
Rollback |
| RLS |
1.2 seconds |
None (ALTER TABLE is fast) |
Instant |
| Schema-per-tenant |
18 minutes |
None (per-schema) |
Per-tenant |
Analysis: RLS wins decisively for schema changes. Schema-per-tenant requires looping through all schemas.
Security Comparison
| Threat |
RLS Risk |
Schema-per-Tenant Risk |
| Missing WHERE clause |
High (leaks all data) |
None (schema isolation) |
| SQL injection |
Medium (can bypass RLS with SET) |
Medium (same) |
| Privilege escalation |
High (shared roles) |
Low (tenant-specific roles) |
| Accidental cross-tenant query |
High |
None |
| Insider threat (DBA access) |
High (sees all data) |
Medium (must query each schema) |
Winner: Schema-per-tenant. The architecture itself prevents most cross-tenant leaks.
Real-World Decision Matrix
Choose Row-Level Security if:
- ✅ You have 10,000+ tenants (schema limit concerns)
- ✅ Tenants are uniform in size and behavior
- ✅ Schema changes are frequent
- ✅ Cost optimization is critical
- ✅ Team is experienced with RLS and security discipline
Choose Schema-per-Tenant if:
- ✅ You have 100-10,000 tenants
- ✅ Tenants have varying schemas or customizations
- ✅ Compliance requires logical separation (e.g., HIPAA)
- ✅ You need per-tenant performance metrics
- ✅ You may move tenants to dedicated databases later
Choose Hybrid if:
- ✅ You have shared infrastructure + tenant-specific business logic
- ✅ You want RLS cost efficiency + schema isolation security
- ✅ You're building a mature B2B SaaS
Migration Path: RLS → Schema-per-Tenant
If you started with RLS and need to migrate:
async function migrateToSchemas() {
const tenants = await pool.query('SELECT id, slug FROM tenants');
for (const tenant of tenants.rows) {
console.log(`Migrating tenant: ${tenant.slug}`);
// 1. Create schema
await pool.query(`CREATE SCHEMA tenant_${tenant.slug}`);
// 2. Copy table structure
await pool.query(`
CREATE TABLE tenant_${tenant.slug}.orders
(LIKE public.orders INCLUDING ALL)
`);
// 3. Copy data
await pool.query(`
INSERT INTO tenant_${tenant.slug}.orders
SELECT * FROM public.orders WHERE tenant_id = $1
`, [tenant.id]);
// 4. Verify count
const { rows: [oldCount] } = await pool.query(
'SELECT COUNT(*) FROM public.orders WHERE tenant_id = $1',
[tenant.id]
);
const { rows: [newCount] } = await pool.query(
`SELECT COUNT(*) FROM tenant_${tenant.slug}.orders`
);
if (oldCount.count !== newCount.count) {
throw new Error(`Migration failed for ${tenant.slug}`);
}
console.log(`Migrated ${oldCount.count} orders for ${tenant.slug}`);
}
// 5. Drop old table after cutover
// await pool.query('DROP TABLE public.orders');
}
FAQs
Can I use both RLS and schemas together?
Yes! Enable RLS within tenant schemas for row-level permissions (e.g., users can only see their own orders):
CREATE TABLE tenant_acme.orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
total DECIMAL
);
ALTER TABLE tenant_acme.orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation ON tenant_acme.orders
USING (user_id = current_setting('app.current_user')::UUID);
Now you have:
- Tenant isolation via schema
- User isolation via RLS
How many schemas can PostgreSQL handle?
No hard limit, but practical ceiling is ~10,000-50,000 schemas. Beyond that:
- pg_catalog bloat slows metadata queries
- Connection overhead increases
- DDL changes become very slow
For 100,000+ tenants, use RLS or database-per-tenant with shard routing.
What about Citus or Vitess for multi-tenancy?
Citus (distributed PostgreSQL):
- Shards by tenant_id automatically
- Best for: 10,000+ tenants with uneven sizes
- Use distribute_table('orders', 'tenant_id')
Vitess (MySQL/Postgres sharding):
- VSchema routes queries to shards
- Best for: Massive scale (1M+ tenants)
- Adds operational complexity
Both are overkill unless you've hit single-instance limits (~50TB data or 100K tenants).
How do I back up schema-per-tenant databases?
Option 1: Full database dump (includes all schemas)
pg_dump -Fc saas_app > backup.dump
Option 2: Per-schema dumps (allows per-tenant restore)
for schema in tenant_*; do
pg_dump -Fc -n $schema saas_app > backup_$schema.dump
done
Option 3: Point-in-time recovery (PITR) with WAL archiving
-- Restore specific schema to point in time
pg_restore -n tenant_acme -t orders backup.dump
Can I move a tenant to a dedicated database?
Yes, with schema-per-tenant:
# Export tenant schema
pg_dump -Fc -n tenant_acme saas_app > tenant_acme.dump
# Restore to new database
createdb tenant_acme_dedicated
pg_restore -d tenant_acme_dedicated -n public tenant_acme.dump
# Update routing
UPDATE tenants
SET database_url = 'postgresql://host/tenant_acme_dedicated'
WHERE slug = 'acme';
With RLS, you'd need:
1. Export rows with WHERE tenant_id = '...'
2. Create new database with same schema
3. Import rows
4. Update routing
Much more error-prone.
Recommendations:
- Start with RLS if you're pre-product/market fit (simplicity wins early)
- Migrate to schema-per-tenant when you hit 100+ paying customers
- Add hybrid model when infrastructure tables (users, billing) stabilize
- Never skip indexing
tenant_id with RLS (or face 100x slowdowns)
- Test migrations on a copy of production before running on 1,000 schemas
Multi-tenancy is a one-way door—choose carefully based on your 5-year tenant count projection, not today's needs. Most successful B2B SaaS companies end up with schema-per-tenant or hybrid models by the time they reach Series A.
For more SaaS architecture patterns, check out our guide on Implementing Rate Limiting and Building Real-Time Features.