One system serving many customers who must never see each other’s data. The isolation model you choose determines your cost, your blast radius, and whether one bad tenant can take down the rest.
Prerequisites: Databases Overview, Sharding Time to read: ~20 minutes
You’re building a SaaS product. A thousand companies use it, each with their own users, data, and settings. They share your infrastructure but must be completely isolated from each other.
The core questions:
The answer is an isolation model, and it’s a spectrum from “everything shared” to “everything dedicated.”
All tenants in the same tables, distinguished by a tenant_id column.
SELECT * FROM orders WHERE tenant_id = :current_tenant AND status = 'pending';
✅ Cheapest by far — one database, maximum resource sharing, one schema to migrate, trivial to add a tenant (insert rows). ✅ Easy cross-tenant analytics (it’s all one table).
❌ 🚨 Weakest isolation, and one forgotten WHERE tenant_id is a cross-tenant data breach. This is
the dominant risk, and it’s a query bug, which means it’s easy to make and catastrophic in effect.
❌ Noisy neighbours — one tenant’s huge dataset or heavy queries affect everyone sharing the table.
❌ No per-tenant customization, backup, or region.
❌ A hot tenant creates a hot partition.
🚨 The mitigation that’s mandatory here: enforce isolation below the application. Application-level
WHERE tenant_id is not enough — one missed clause is a breach. Use row-level security (RLS):
-- Postgres RLS: the database enforces tenant isolation, not the application
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Now every query is automatically filtered, even if the app forgets.
RLS moves isolation from “every developer must remember” to “the database guarantees it.” For shared-everything multi-tenancy, this is close to non-negotiable, and mentioning it is a strong signal.
One database, a schema per tenant (tenant_a.orders, tenant_b.orders).
✅ Stronger isolation — queries can’t accidentally cross schemas. ✅ Per-tenant schema customization is possible. ✅ Per-tenant backup and restore.
❌ 🚨 Doesn’t scale to many tenants. Thousands of schemas strain the database catalog, and a schema migration must run across every schema — a migration touching 5,000 schemas is a genuine operational problem. ❌ Still shares database resources (noisy neighbours at the instance level).
Good for tens to low-hundreds of tenants; painful beyond that.
Each tenant gets its own database (on shared or dedicated hardware).
✅ Strong isolation — a query cannot cross databases. ✅ Per-tenant backup, restore, migration, tuning, region, and even database version. ✅ Easy to move a large tenant to dedicated hardware. ✅ Compliance-friendly (a tenant’s data is physically separable).
❌ Expensive and operationally heavy — thousands of databases to provision, monitor, back up, and migrate. ❌ Cross-tenant analytics requires aggregating across databases. ❌ Adding a tenant means provisioning a database (slower, needs automation).
Good for high-value, low-count tenants, or when compliance demands it.
Each tenant gets its own everything — app servers, database, sometimes a whole cloud account.
✅ Maximum isolation — a compromise of one tenant doesn’t touch others; complete performance isolation. ✅ Full customization; per-tenant SLAs; strictest compliance.
❌ Most expensive, most operational overhead, no resource sharing.
Good for enterprise deals where the customer pays for and demands it.
| Model | Isolation | Cost | Scales to many tenants | Customization | Noisy neighbour risk |
|---|---|---|---|---|---|
| Shared everything | Weak (app/RLS) | 💚 Cheapest | ✅ Excellent | ❌ None | 🔴 High |
| Separate schemas | Medium | 💛 Low | ⚠️ Hundreds | ⚠️ Some | 🟡 Medium |
| Separate database | Strong | 🧡 High | ⚠️ With automation | ✅ Good | 🟢 Low |
| Fully dedicated | Strongest | 🔴 Highest | ❌ Poor | ✅ Full | 🟢 None |
🎙️ The answer that shows judgment — a tiered model: “I’d use shared-everything with row-level security for the long tail of small tenants, where cost efficiency matters most. Large enterprise tenants who pay for isolation get a dedicated database. The tier is a property of the tenant, and the routing layer sends each request to the right place. This is exactly how Salesforce and Shopify are structured.”
🚨 This tiered/hybrid answer is the strong one. Most real SaaS platforms don’t pick one model — they pick per tenant based on size and contract.
A pattern worth knowing by name. Instead of one giant shared system, run multiple complete copies of the system (“cells” or “pods”), each serving a subset of tenants.
Cell 1: app + db → tenants 1–1000
Cell 2: app + db → tenants 1001–2000
Cell 3: app + db → tenants 2001–3000
🚨 The benefit is blast radius. A bad deploy, a corrupted database, or a cascading failure affects one cell — say 1,000 tenants — not all 100,000. It’s horizontal scaling applied to the whole stack, with tenant-level isolation between cells.
Shopify’s “pods” are the canonical example: each pod is a self-contained slice of the platform serving a subset of merchants, so a pod failure affects only its merchants. → Scalability
Within a cell you can still use any of the four isolation models. Cells are about blast radius and scaling, orthogonal to data isolation.
🚨 The performance-isolation half, and it’s separate from data isolation.
Even with perfect data isolation, one tenant running 10,000 heavy queries can starve the shared database, CPU, or connection pool, degrading everyone.
Mitigations:
| Technique | Effect |
|---|---|
| Per-tenant rate limiting | Cap each tenant’s request rate → Rate Limiting |
| Per-tenant resource quotas | Storage, compute, connection limits per tenant |
| Per-tenant connection pools / bulkheads | One tenant can’t exhaust shared connections → Bulkheads |
| Move heavy tenants to dedicated infra | The tiered model — the “whale” gets its own database |
| Fair scheduling / weighted queuing | Prevent one tenant monopolizing workers |
| Separate cells | Bound the blast radius |
🚨 The “whale tenant” is the recurring problem. One customer 1,000× larger than the rest breaks even-distribution assumptions — a hot partition, a connection hog, a backup that takes hours. The standard answer is to detect large tenants and give them dedicated resources — which is exactly what the tiered model enables.
Tenant identification. Every request must carry the tenant context — from a subdomain
(acme.app.com), a JWT claim, an API key, or a path prefix. 🚨 This must be resolved early and
propagated through the entire request, and it must be un-spoofable (derived from an authenticated
token, not a client-supplied header). → API Gateway
Onboarding a tenant. Shared-everything: insert a row (instant). Dedicated database: provision, migrate, seed (needs automation, slower). Your isolation model determines your signup latency.
Per-tenant configuration. Feature flags, limits, and settings scoped per tenant. → Feature Flags
Backup and restore. Shared-everything makes “restore just this one tenant” genuinely hard — you can’t easily restore one tenant’s rows from a whole-database backup without affecting others. Dedicated databases make per-tenant restore trivial. This is an underappreciated argument for more isolation.
Data residency. GDPR and national data-localization laws may require EU tenants’ data to stay in the EU. Shared-everything makes this nearly impossible; per-database or per-cell makes it straightforward (route the tenant to a region). → Privacy & Compliance
Tenant deletion. “Delete everything for this tenant” (GDPR, offboarding) is a DELETE WHERE
tenant_id in shared-everything (and you’d better get it exactly right) versus dropping a database in
the dedicated model.
| Decision | Gain | Cost |
|---|---|---|
| Shared everything | Cheapest; scales to many tenants; easy analytics | Weakest isolation; noisy neighbours; hard per-tenant ops |
| Row-level security | Isolation enforced by the database, not the app | Some query overhead; per-database feature |
| Separate database | Strong isolation; per-tenant ops and residency | Expensive; operationally heavy; harder analytics |
| Tiered / hybrid | Cost efficiency + isolation where it’s paid for | Two+ code paths; routing complexity |
| Cell architecture | Bounded blast radius; scales the whole stack | Operational complexity of many cells |
OrgId on every row, serving hundreds of thousands of orgs, with heavy investment in query
governors and per-tenant limits to manage noisy neighbours. It proves shared-everything scales to
enormous tenant counts if you engineer the isolation carefully.WHERE tenant_id. One missed clause is a breach. Use RLS or a
database-enforced boundary.1. Cause a cross-tenant breach, then prevent it. Build a shared table with tenant_id and a
query layer. Deliberately write one query that forgets the WHERE tenant_id. Watch it return another
tenant’s data. Then enable Postgres row-level security and watch the same buggy query return
nothing. This is the single most important exercise in the chapter — it makes RLS’s value visceral.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_iso ON orders USING (tenant_id = current_setting('app.tenant')::uuid);
SET app.tenant = 'tenant-a';
SELECT * FROM orders; -- only tenant-a's rows, no WHERE needed
2. Create a noisy neighbour. Two tenants sharing a database. Have one run heavy queries and measure the other’s latency. Watch it degrade. Then add per-tenant connection limits and measure again.
3. Compare onboarding. Time “add a tenant” in the shared model (insert a row) versus the dedicated model (provision and migrate a database). The difference is a real product constraint — instant signup versus a provisioning delay.
4. Model the whale. Populate one tenant with 1,000× the data of the others in a shared table. Observe how it affects shared indexes, backup time, and query plans. That’s why whales get dedicated infrastructure.
WHERE tenant_id insufficient for isolation?