system-design

Multi-Tenancy

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


The problem

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.”


The isolation models

1. Shared everything — one row set, a tenant_id column

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.

2. Shared database, separate schemas

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.

3. Separate database per tenant

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.

4. Fully dedicated (single-tenant instances)

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.


The comparison

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.


The pod / cell architecture

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 noisy neighbour problem

🚨 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.


Cross-cutting concerns

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.


⚖️ Trade-offs

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

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

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.


Check yourself

1. Why is application-level WHERE tenant_id insufficient for isolation? Because it depends on every developer remembering it on every query, forever — and a single omission is not a minor bug but a cross-tenant data breach, potentially exposing every customer's data to another. It's exactly the kind of mistake that's easy to make (a new query, a refactor, an ORM default) and catastrophic in effect. The fix is to enforce isolation *below* the application, where it can't be forgotten: **row-level security** in the database automatically filters every query by the current tenant context, so even a query with no `WHERE` clause returns only the right tenant's rows. Defense in depth — RLS plus application filtering plus tests — is the responsible posture.
2. What are the four isolation models, from cheapest to most isolated? **Shared everything** — all tenants in the same tables with a `tenant_id` column: cheapest, scales to huge tenant counts, easy analytics, but weakest isolation and highest noisy-neighbour risk. **Separate schemas** — one database, a schema per tenant: stronger isolation and per-tenant backup, but doesn't scale past low hundreds of tenants (migrations must touch every schema). **Separate database per tenant** — strong isolation, per-tenant backup/restore/region/version, but expensive and operationally heavy. **Fully dedicated** — separate everything per tenant: maximum isolation and customization, highest cost, poor tenant-count scaling. The mature answer combines them in a tiered model rather than picking one.
3. What's the difference between data isolation and performance isolation? **Data isolation** ensures tenant A can never *read or write* tenant B's data — a correctness and security property, addressed by `tenant_id` filtering, row-level security, separate schemas, or separate databases. **Performance isolation** ensures tenant A's *workload* can't degrade tenant B's experience — the "noisy neighbour" problem — which is separate: you can have perfect data isolation while one tenant's heavy queries starve the shared database, CPU, or connection pool. Performance isolation needs per-tenant rate limits, resource quotas, connection bulkheads, fair scheduling, or moving heavy tenants to dedicated infrastructure. Solving one does not solve the other.
4. What is a cell (or pod) architecture and what problem does it solve? Running multiple complete, independent copies of the entire system, each serving a subset of tenants — e.g. cell 1 serves tenants 1–1,000, cell 2 serves 1,001–2,000, each with its own app tier and database. It solves **blast radius**: a bad deploy, a corrupted database, a cascading failure, or a runaway tenant affects only its cell (say 1,000 tenants) rather than the entire platform (100,000). It's horizontal scaling applied to the whole stack with tenant-level fault boundaries between cells, and it's orthogonal to the data-isolation model (any of the four models can be used within a cell). Shopify's pods are the canonical example.
5. Why do large "whale" tenants push you toward more isolation? Because they break the assumptions shared infrastructure relies on. A tenant 1,000× larger than the median creates a hot partition, dominates shared indexes, monopolizes the connection pool, makes whole-database backups take hours, and can single-handedly degrade every other tenant on the same resources. In a shared-everything model there's no way to contain them. The standard response is the tiered model: detect large tenants and give them a dedicated database or dedicated cell, so their scale is isolated and they can be tuned, backed up, and scaled independently — which is also usually justified because such tenants are paying enterprise contracts that fund the dedicated resources.

Further reading