Multi-Tenant Database Design: Shared vs Separate
Architecture Patterns — Part 3 of 30
The Missing WHERE Clause
It started with a routine billing job.
The startup had been running happily for two years with 400 customers on a shared-schema multi-tenant database. One tenant_id column on every table, a layer of middleware to inject it into queries, and a mental model of "we'll be fine."
Then a senior engineer refactored the monthly invoice-generation job. Clean code. Proper abstraction. Unit tested. But the new helper function that fetched usage data accepted a database connection directly instead of going through the tenant-aware middleware. One code path. One missed filter. The billing run that night aggregated usage data across all tenants and generated invoices that were a mixture of data from multiple customers.
They caught it before the invoices went out — barely. The post-mortem identified 23 places in the codebase where similar patterns existed. The fix took three engineers two weeks.
This is not a story about bad engineers. The team was competent. The original architecture was reasonable. The problem was that nobody had made an explicit decision about what tenant isolation actually meant — which layer enforced it, how it was tested, and what happened when application code bypassed it.
That's what this article is about. Not the mechanics of multi-tenancy (you can find those anywhere). The decision framework for choosing an isolation model, understanding its failure modes, and building defenses that don't depend on every developer remembering to add a WHERE clause.
The Three Models (And What Nobody Tells You About Them)
Every multi-tenant database architecture lives somewhere on this spectrum:
Shared Everything ←────────────────────────────→ Separate Everything
(Pool Model) Hybrid (Silo Model)
Model 1: Shared Database, Shared Schema (Pool)
All tenants share one database, one schema, one set of tables. Tenant data is separated by a tenant_id column that every table carries.
-- Everyone shares this table
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
customer_id UUID NOT NULL,
total DECIMAL(10,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- The index you absolutely cannot forget
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_tenant_customer ON orders(tenant_id, customer_id);
Every query must include tenant_id:
# Python / SQLAlchemy pattern
from sqlalchemy.orm import Session
from contextvars import ContextVar
current_tenant: ContextVar[str] = ContextVar('current_tenant')
class TenantAwareQuery:
def __init__(self, session: Session):
self.session = session
self.tenant_id = current_tenant.get()
def get_orders(self, customer_id: str):
# tenant_id filter is explicit and required
return (
self.session.query(Order)
.filter(
Order.tenant_id == self.tenant_id,
Order.customer_id == customer_id
)
.all()
)
The real failure mode: Application-layer enforcement means every developer must remember to filter. Background jobs, admin scripts, analytics queries, data migrations — all of them need the filter. The moment one doesn't have it, you have a cross-tenant data leak. This is precisely what happened in the billing job story above. According to Nalashaa's 2025 analysis of multi-tenant migration failures, common failure points include queries missing tenant filters, background jobs processing cross-tenant data, and migration scripts altering unintended schemas.
When it makes sense: Early-stage products with homogeneous customers, B2C SaaS, free/basic tier infrastructure. Lowest cost, simplest operations.
Model 2: Shared Database, Separate Schemas (Hybrid)
One database server, but each tenant gets their own schema: tenant_acme.orders, tenant_widget.orders. Same table structure, logically separated.
-- Provisioning a new tenant in PostgreSQL
CREATE SCHEMA tenant_acme_corp;
CREATE TABLE tenant_acme_corp.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
total DECIMAL(10,2),
created_at TIMESTAMPTZ DEFAULT NOW()
-- Note: no tenant_id needed — the schema IS the isolation
);
-- Set the search path per connection
SET search_path TO tenant_acme_corp, public;
In application code, this usually looks like:
// TypeScript / Node.js with pg
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function getConnection(tenantSlug: string) {
const client = await pool.connect();
// Set schema for this connection's lifetime
await client.query(`SET search_path TO tenant_${tenantSlug}, public`);
return client;
}
// Usage
const client = await getConnection('acme_corp');
const result = await client.query('SELECT * FROM orders WHERE customer_id = $1', [customerId]);
// This query hits tenant_acme_corp.orders automatically
await client.release();
The real failure mode: Schema migrations. Every time you add a column, change a constraint, or create a new table, you run that migration across every tenant schema. With 50 tenants it's manageable. With 5,000 tenants, a migration that fails halfway through is an operational disaster.
# The migration problem at scale
# With 500 tenant schemas, this takes a while...
for schema in $(psql $DATABASE_URL -t -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'"); do
echo "Migrating $schema..."
psql $DATABASE_URL -c "ALTER TABLE ${schema}.orders ADD COLUMN metadata JSONB;"
done
# If this fails at schema #347, you have a split-brain schema problem
When it makes sense: Mid-market B2B SaaS, 50–5,000 tenants, customers who need some data isolation guarantees but not full database separation. Common in healthcare and finance where schema-level separation satisfies many compliance auditors.
Model 3: Separate Database Per Tenant (Silo)
Each tenant gets their own database instance. Complete resource isolation.
# Python: provisioning a new tenant database
import subprocess
from dataclasses import dataclass
@dataclass
class TenantDatabase:
tenant_id: str
database_name: str
connection_string: str
def provision_tenant_database(tenant_id: str, tenant_slug: str) -> TenantDatabase:
"""Provision a dedicated database for a new tenant."""
db_name = f"tenant_{tenant_slug}"
# Create isolated database
subprocess.run([
'createdb',
'--host', DB_HOST,
'--username', ADMIN_USER,
db_name
], check=True)
# Run baseline migrations
subprocess.run([
'python', '-m', 'alembic', 'upgrade', 'head',
'--url', f'postgresql://{ADMIN_USER}@{DB_HOST}/{db_name}'
], check=True)
return TenantDatabase(
tenant_id=tenant_id,
database_name=db_name,
connection_string=f'postgresql://{ADMIN_USER}@{DB_HOST}/{db_name}'
)
The real failure mode: Cost and operational overhead. Running 1,000 tenant databases on traditional infrastructure (like AWS RDS) means 1,000 instances with baseline compute costs — even for tenants who are barely active. Until recently, this made the silo model economically viable only for enterprise customers.
This is changing. Neon's April 2025 analysis of the noisy neighbor problem documents how serverless database architectures — where compute and storage are decoupled — now make the database-per-tenant model financially viable even for small tenants, because compute scales to zero when idle. The economics that made silo architecture an enterprise-only option are eroding fast.
When it makes sense: Enterprise B2B SaaS with compliance requirements (HIPAA, SOC 2, FedRAMP), customers who contractually require data residency, or any tenant whose data breach would be catastrophically damaging to your business.
The Decision Framework
Stop asking "which model is best?" The right question is: what are the consequences when isolation breaks?
Axis 1: Breach Severity
If Tenant A accidentally sees Tenant B's data, how bad is it?
- Low: Social app, consumer product, anonymized data → Pool model is fine
- Medium: B2B SaaS with business-sensitive data → Schema separation adds meaningful protection
- High: Healthcare, finance, legal, government → Database-per-tenant, or you're taking regulatory and legal risks
Axis 2: Tenant Count and Growth Rate
Tenant Count: 1-100 100-10,000 10,000+
│ │ │
Recommendation: Pool Pool or Schema Hybrid or
Separation Cell-based
The hybrid model — pool for standard customers, silo for enterprise — is the most widely adopted approach in 2025 production SaaS systems. It aligns infrastructure cost with revenue: your $50/month customers share infrastructure, your $50,000/year customers get dedicated resources.
Axis 3: Compliance Requirements
This one overrides everything else. If a customer's data is subject to:
- GDPR with data residency requirements → You may need per-tenant databases in specific regions
- HIPAA → Business Associate Agreements often imply or require strong physical separation
- SOC 2 Type II → Auditors will ask how you prevent cross-tenant data access
- FedRAMP → Database-per-tenant is essentially required
As Redis documented in their February 2026 multi-tenant isolation guide: "European supervisory authorities have issued billions in GDPR fines since the regulation took effect. Healthcare SaaS platforms can face substantial HIPAA settlements for violations, often reaching into the millions of dollars."
The Defense-in-Depth Approach
Here's the thing nobody puts in architecture diagrams: the isolation model you choose isn't your only defense. Defense-in-depth means you layer multiple enforcement mechanisms so that a failure in one doesn't cause a breach.
Layer 1: Database-Level Enforcement with RLS
For the pool model, Row Level Security (RLS) in PostgreSQL is a game-changer. It moves isolation enforcement from application code (which every developer must remember) to the database itself (which nobody can forget).
-- Enable RLS on every tenant-scoped table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- applies even to table owner
-- Create a function to get current tenant from session variable
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS UUID AS $$
SELECT current_setting('app.tenant_id', true)::UUID;
$$ LANGUAGE sql STABLE;
-- The policy: every query automatically filters by tenant
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
Now your application sets the tenant context at the start of each request, and the database handles the rest:
# Python: setting tenant context per request
from contextlib import contextmanager
from sqlalchemy import text
@contextmanager
def tenant_context(session, tenant_id: str):
"""Set PostgreSQL session variable for RLS enforcement."""
session.execute(text("SET LOCAL app.tenant_id = :tid"), {"tid": tenant_id})
try:
yield session
finally:
# SET LOCAL is automatically rolled back with the transaction
pass
# In your request handler (FastAPI example):
@app.get("/orders")
async def get_orders(tenant_id: str = Depends(get_current_tenant)):
with db.begin():
with tenant_context(db, tenant_id):
# This query will ONLY return current tenant's orders
# even if the developer forgets to add WHERE tenant_id = ...
return db.execute(text("SELECT * FROM orders")).fetchall()
With RLS in place, the billing job that caused our opening story's incident would have been safe. Even if the developer bypassed the middleware, the database would still enforce the filter.
Layer 2: Connection Pool Isolation
For schema-separated or database-per-tenant models, use dedicated connection pools per tenant. Don't let tenant connections bleed into each other:
// TypeScript: per-tenant connection pool management
import { Pool } from 'pg';
const tenantPools = new Map<string, Pool>();
function getTenantPool(tenantId: string, connectionString: string): Pool {
if (!tenantPools.has(tenantId)) {
tenantPools.set(tenantId, new Pool({
connectionString,
max: 10, // cap connections per tenant
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}));
}
return tenantPools.get(tenantId)!;
}
// Each tenant's requests go through their own pool
async function queryForTenant<T>(
tenantId: string,
query: string,
params: unknown[]
): Promise<T[]> {
const connStr = await getTenantConnectionString(tenantId);
const pool = getTenantPool(tenantId, connStr);
const { rows } = await pool.query<T>(query, params);
return rows;
}
Layer 3: The Noisy Neighbor Guard
Even perfect logical isolation doesn't protect against resource contention. One tenant running a complex report can degrade performance for everyone else on shared infrastructure. Neon's research documents specific patterns: CPU monopolization from complex joins, memory pressure from large result sets, I/O saturation from bulk exports.
Mitigation for the pool model:
-- PostgreSQL: per-tenant query timeouts (set at connection time)
-- Enterprise tenants get 60s, basic tenants get 10s
ALTER ROLE tenant_basic_role SET statement_timeout = '10s';
ALTER ROLE tenant_enterprise_role SET statement_timeout = '60s';
-- Per-tenant resource groups (PostgreSQL 16+)
CREATE RESOURCE GROUP basic_tier
WITH (CPU_RATE_LIMIT = 10, MEMORY_LIMIT = 512);
ALTER ROLE tenant_basic_role RESOURCE GROUP basic_tier;
For application-level rate limiting by tenant:
import time
from collections import defaultdict
from threading import Lock
class TenantRateLimiter:
"""Sliding window rate limiter per tenant."""
def __init__(self, requests_per_minute: dict[str, int]):
# tier -> max requests per minute
self.limits = requests_per_minute
self.windows: dict[str, list[float]] = defaultdict(list)
self.lock = Lock()
def is_allowed(self, tenant_id: str, tier: str) -> bool:
now = time.time()
window_start = now - 60 # 1 minute window
limit = self.limits.get(tier, 60)
with self.lock:
# Clean old entries
self.windows[tenant_id] = [
t for t in self.windows[tenant_id] if t > window_start
]
if len(self.windows[tenant_id]) >= limit:
return False
self.windows[tenant_id].append(now)
return True
# Usage
limiter = TenantRateLimiter({
'basic': 60,
'professional': 300,
'enterprise': 1000
})
The Real-World Incident Pattern
In February 2026, a cross-tenant data access vulnerability was disclosed in Stripo's AI Hub Campaign feature — an improper access control flaw (CWE-284) that allowed one tenant's campaign data to be accessed by another. Stripo is a popular email design platform used by thousands of businesses. The vulnerability was resolved, but the disclosure illustrates the pattern: AI features often introduce new data access paths that weren't part of the original isolation design.
This is the new frontier for multi-tenancy in 2025–2026. As security researchers noted in early 2026, AI-powered SaaS features introduce semantic data leakage that traditional SQL isolation doesn't address. When tenants share a vector store for RAG-based features, a missing metadata filter on a similarity search can return Tenant B's documents in response to Tenant A's query — without any SQL injection or authentication bypass required.
The architecture implication: your isolation model must extend to every data store your application uses, not just your primary relational database. Cache layers, vector databases, message queues, and object storage all need tenant-aware access controls.
Choosing Your Migration Path
Most teams start with pool and need to evolve. Here's the safe progression:
Stage 1 → Stage 2: Pool to Schema Separation
# Migration script: move tenant from shared schema to dedicated schema
#!/bin/bash
TENANT_ID=$1
TENANT_SLUG=$2
# 1. Create new schema
psql $DATABASE_URL -c "CREATE SCHEMA tenant_${TENANT_SLUG};"
# 2. Copy data (keeping the old data as backup)
psql $DATABASE_URL << EOF
INSERT INTO tenant_${TENANT_SLUG}.orders
SELECT id, customer_id, total, created_at
FROM public.orders
WHERE tenant_id = '${TENANT_ID}';
EOF
# 3. Verify row counts match
OLD_COUNT=$(psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM public.orders WHERE tenant_id = '${TENANT_ID}';")
NEW_COUNT=$(psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM tenant_${TENANT_SLUG}.orders;")
if [ "$OLD_COUNT" = "$NEW_COUNT" ]; then
echo "Migration verified: $OLD_COUNT rows"
else
echo "MISMATCH: old=$OLD_COUNT new=$NEW_COUNT — rolling back"
psql $DATABASE_URL -c "DROP SCHEMA tenant_${TENANT_SLUG} CASCADE;"
exit 1
fi
# 4. Update tenant config to point to new schema
# 5. Delete from shared schema only after confirmed working
The Architecture Decision Checklist
Before you ship any multi-tenant data feature, answer these questions:
Isolation model selection:
- What is the worst-case impact if Tenant A sees Tenant B's data?
- What compliance frameworks apply to your customers? (HIPAA, GDPR, SOC 2, FedRAMP)
- How many tenants do you have today? How many in 2 years?
- Are your enterprise customers willing to pay a premium for dedicated infrastructure?
- Do you have geographic data residency requirements?
Implementation:
- Is tenant isolation enforced at the database layer (RLS), not just application code?
- Does every table that holds tenant data have
tenant_idindexed? - Are all background jobs, migration scripts, and admin tools tenant-aware?
- Have you tested cross-tenant access by deliberately trying to bypass isolation?
- Do your join queries propagate tenant context through all related tables?
Operations:
- Are per-tenant query timeouts and resource limits configured?
- Do you have monitoring for noisy neighbor patterns (one tenant consuming disproportionate resources)?
- Can you migrate a single tenant to a dedicated database without downtime?
- Does your backup and restore process work per-tenant (not just full-database)?
AI and extended data stores:
- If you use a vector database or embedding store, is tenant context enforced at query time?
- Is your caching layer using tenant-namespaced keys? (e.g.,
tenant:{id}:resource:{id}) - Are S3/object storage paths prefixed by tenant ID with IAM policies restricting access?
The Summary Decision Tree
What isolation level do you need?
│
├─ Breach consequence: LOW (consumer product, anonymized data)
│ └─ Pool Model + RLS
│ Simple, cheap, fast to build
│ Must enforce RLS at DB layer — not just app code
│
├─ Breach consequence: MEDIUM (B2B SaaS, business data)
│ ├─ < 5,000 tenants?
│ │ └─ Schema-per-tenant
│ └─ > 5,000 tenants?
│ └─ Hybrid: Pool for standard, Schema/DB for enterprise
│
└─ Breach consequence: HIGH (HIPAA, FedRAMP, financial, legal)
└─ Database-per-tenant (Silo)
Consider serverless DB (Neon, PlanetScale) to manage cost
Traditional RDS-per-tenant is economically unviable at scale
Ask The Guild
This week's community prompt:
What isolation model are you running in production — and have you ever had a close call with cross-tenant data leakage?
I'm especially curious about the teams who started with pool model and migrated to schema-per-tenant or silo. What triggered the migration? Was it a compliance requirement, a near-miss incident, an enterprise customer demanding isolation, or just architectural conscience? And what surprised you most about the migration?
Post your story in the Guild forum under #architecture-patterns. If you've found a clever way to test isolation (automated cross-tenant access tests, chaos engineering for multi-tenancy), share that too — those techniques tend to be severely underrepresented in the literature.
The scariest multi-tenant bugs are the ones you find in production reviews, not security audits.
Tom Hundley is a software architect with 25 years of experience building and breaking production systems. He currently teaches architecture patterns to the next generation of vibe coders at the AI Coding Guild.