Multi-Tenancy
Building SaaS that serves many customers from one system — without leaking their data into each other.
What Multi-Tenancy Means
A multi-tenant system serves multiple customers (tenants) from a single deployment. Slack, Notion, Salesforce, Linear — all multi-tenant. One codebase, one set of servers, one (or a few) databases. Each tenant sees a personalized experience and their data only.
The alternative is single-tenant: each customer gets their own dedicated deployment. Higher isolation, much higher cost — both in infrastructure and operations. Most modern SaaS is multi-tenant by default; single-tenant is reserved for enterprise customers with strict compliance requirements.
The fundamental challenge of multi-tenancy: how do you ensure tenant A can never, under any circumstance, see or affect tenant B's data?
Get this wrong and you have a "cross-tenant data leak" — the kind of bug that ends companies. Get it right and you serve thousands of customers from one stack.
Three Database Patterns
How you structure data is the first big decision. There are three common patterns, in increasing isolation order:
┌──────────────────────────────────────────────────────────┐
│ 1. Shared Database, Shared Schema (pooled) │
│ ────────────────────────────────────── │
│ ┌────────────────────────────────────────┐ │
│ │ orders table │ │
│ │ id │ tenant_id │ amount │ ... │ │
│ │ 1 │ abc │ 100 │ │ │
│ │ 2 │ abc │ 50 │ │ │
│ │ 3 │ xyz │ 75 │ ← different │ │
│ │ 4 │ xyz │ 20 │ tenant │ │
│ └────────────────────────────────────────┘ │
│ Every query filters by tenant_id │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 2. Shared Database, Schema-per-Tenant │
│ ───────────────────────────────────── │
│ tenant_abc tenant_xyz │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ orders │ │ orders │ │
│ │ users │ │ users │ │
│ │ ... │ │ ... │ │
│ └──────────────┘ └──────────────┘ │
│ Same database; isolated PostgreSQL schemas │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 3. Database-per-Tenant (silo) │
│ ────────────────────────── │
│ abc.db.example.com xyz.db.example.com │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ orders │ │ orders │ │
│ │ users │ │ users │ │
│ └──────────────┘ └──────────────┘ │
│ Completely separate databases │
└──────────────────────────────────────────────────────────┘
Pattern 1 — Shared Database, Shared Schema (Pooled)
Every table has a tenant_id column. Every query filters by it. One database, one schema for everyone.
Pros: cheapest to operate, easiest to add tenants (just an INSERT), straightforward analytics across tenants.
Cons: weakest isolation — one missed WHERE tenant_id = ? is a data leak. Noisy neighbors — one tenant's heavy query slows others. Hard to give one tenant their own backup or migration.
Pattern 2 — Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema (or MySQL database). Same physical database, but logical isolation between tenants.
Pros: stronger isolation than shared schema. Per-tenant migrations possible (one tenant on schema v3, another on v4).
Cons: Bytebase and other DB vendors recommend AGAINST this pattern in 2026 — it's complex like database-per-tenant but doesn't give you the isolation benefits. Use only if you have very specific reasons.
Pattern 3 — Database-per-Tenant (Silo)
Each tenant gets a fully separate database, possibly on different infrastructure.
Pros: hard isolation (a leak is essentially impossible). Compliance-friendly. Tenant-specific backup/restore. No noisy neighbors.
Cons: operational nightmare — N databases to migrate, backup, monitor. Connection pool exhaustion at scale. Cross-tenant analytics requires aggregation.
The 2026 recommended default: start with shared database + shared schema. Move to database-per-tenant only when compliance, scale, or specific customer demands require it. The hybrid model — pool most tenants together, give enterprise customers their own database — is the most common pattern in mature SaaS.
Tenant Resolution — How You Know Who's Asking
Every request needs to identify which tenant it belongs to. This is "tenant resolution," and there are several common strategies:
Subdomain — `acme.yourapp.com` vs `globex.yourapp.com`. Resolved from the Host header.
Pros: visible to users, supports custom domains via CNAME
Cons: requires wildcard DNS, harder for CLI tools
JWT claim — the user's auth token includes `tenant_id`. Resolved when verifying the token.
Pros: cryptographically tied to the user, no host trickery
Cons: tokens can't switch tenants without re-issuing
URL path — `/t/acme/dashboard` vs `/t/globex/dashboard`. Resolved from the URL.
Pros: works without DNS magic
Cons: looks ugly, breaks REST resource hierarchy
API key — partner integrations send Authorization: Bearer abc123 where the key maps to a tenant.
Most production apps use a combination: JWT for end users, subdomains for branded login pages, API keys for partners. Build tenant resolution as a pluggable strategy from day one — you'll add ways over time.
Once resolved, the tenant ID is attached to the request context (Module 8). Every downstream layer reads it from there:
// Middleware sets it once
app.use(async (req, res, next) => {
const tenantId = await resolveTenant(req);
if (!tenantId) return res.status(401).send('Unauthorized');
req.context.tenantId = tenantId;
next();
});
// Repository layer uses it automatically
async function findOrders(ctx) {
return await db.query(
'SELECT * FROM orders WHERE tenant_id = $1',
[ctx.tenantId]
);
}
The Tenant Filter Discipline
In a shared-schema model, every single query that touches tenant data must filter by tenant_id. Forget once, and tenant A can read tenant B's data.
Defending this is harder than it sounds. Approaches in order of strength:
Level 1 — Code review. Hope every query gets it right. This will fail eventually.
Level 2 — Repository functions take tenant_id as a parameter. Every data access goes through these wrappers; the tenant filter is applied automatically. Better, but still possible to write a one-off query that bypasses them.
Level 3 — ORM scoping. ORMs like Sequelize, Prisma, ActiveRecord support default scopes: every query through this model automatically includes the tenant filter. You'd have to deliberately bypass it.
Level 4 — Database-level enforcement. PostgreSQL Row-Level Security (RLS) lets the database itself enforce that queries only return rows matching the current session's tenant. Even a buggy query can't return another tenant's data because the database refuses.
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Define the policy: only see rows where tenant_id matches session var
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Application sets the tenant for every connection
SET app.tenant_id = 'abc123';
SELECT * FROM orders; -- automatically filtered
RLS is the gold standard for shared-schema multi-tenancy. It's a fundamental safety net — even if your application code has a bug, the database enforces isolation.
Beyond queries, you also need:
Tenant-aware caching — Redis keys must include the tenant ID. orders:123 is a leak waiting to happen. tenant_abc:orders:123 is safe.
Tenant-aware logs — log entries should include tenant_id so you can scope debugging without seeing across customers (privacy + compliance).
Tenant-aware tests — Write integration tests that insert data as Tenant A, then query as Tenant B. Assert zero rows returned. Run this in CI on every change.
Operational Concerns at Scale
Multi-tenancy isn't just a database decision; it shapes every operational concern.
Resource limits per tenant — Without limits, one heavy customer can starve others. Common controls:
• Rate limits per tenant (not just per user)
• Query timeout per tenant
• Storage quotas
• Background job concurrency limits per tenant
• Connection pool fair-share
Per-tenant configuration — Different tenants want different settings: their logo, their email templates, feature flags ("Tenant ABC has the new beta dashboard"). Build a tenants table early with extensible JSON config; resist the urge to fork code per customer.
Per-tenant migrations — In schema-per-tenant or database-per-tenant patterns, schema changes have to run against every tenant. Build automation that:
• Migrates one tenant first (canary)
• Verifies success before moving on
• Can resume from where it left off if interrupted
• Uses zero-downtime techniques (add column, backfill, swap)
Onboarding new tenants — When a customer signs up, you need to create their schema/database, seed initial data, configure default settings, and provision their integrations. Make this a single command or API call; if it requires manual work, you'll be the bottleneck.
Offboarding — Customers leave. GDPR requires you can delete their data on request. Plan for "delete tenant" as a deliberate, audited operation that purges everything cleanly.
Cross-tenant features — Some features genuinely cross tenants (admin dashboards, fraud detection, support tools). Build these as separate, audited code paths with stricter access controls. Mixing them with regular tenant-scoped code is where leaks happen.
⁂ Back to all modules