Multi-tenancy is the decision that quietly shapes every other decision in a SaaS product. Get it right and you scale to thousands of customers on a lean infrastructure budget. Get it wrong and you inherit a migration project that blocks the roadmap for a quarter.
Which tenant isolation model should you choose?
There is no single correct answer — only trade-offs between isolation, cost and operational complexity. In practice you'll pick one of three models, or blend them for different tiers of customer.
- Shared database, shared schema — a tenant_id column on every row. Cheapest and simplest; relies on application + row-level security for isolation.
- Shared database, schema per tenant — stronger logical isolation, but schema sprawl becomes painful past a few hundred tenants.
- Database per tenant — maximum isolation and the easiest path to per-tenant backups and residency, but the most operationally heavy.
For most B2B SaaS, start with shared-schema + row-level security and reserve database-per-tenant for enterprise plans that demand it.
How do you enforce isolation at the database?
Application-layer filtering is one forgotten WHERE clause away from a data leak. PostgreSQL Row-Level Security makes the database itself refuse to return another tenant's rows.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- set per request, inside a transaction
SET LOCAL app.tenant_id = '...';When should billing enter the architecture conversation?
Wire usage metering in from day one. Even if you launch with flat pricing, recording per-tenant usage events lets you introduce metered or seat-based plans later without a data backfill.
The cheapest time to add a tenant_id and a usage event is before you have customers. The most expensive is after you have ten thousand.
What breaks first in a shared-database SaaS?
In production, the failure modes are predictable — and all of them are cheaper to design against than to retrofit:
- Noisy neighbours: one tenant's reporting query saturates the shared connection pool and everyone's latency spikes.
- Forgotten tenant_id on a new table — RLS has no policy to enforce, so the table silently leaks across tenants until an audit catches it.
- Cache key collisions when Redis keys aren't prefixed with the tenant, serving customer A's dashboard to customer B.
- Background jobs and webhooks that run outside the request lifecycle and lose tenant context entirely.
- Cross-tenant analytics queries that were fine at ten tenants but table-scan millions of rows at ten thousand.
Each has a boring fix: statement timeouts and per-tenant pool limits for noisy neighbours; a migration check that fails CI when a new table lacks RLS; tenant-prefixed cache helpers; tenant context passed explicitly into every job payload; and summary tables built per tenant rather than global scans.
How do you prove isolation actually works?
Trust, then verify. Write automated tests that log in as tenant A and assert that every query touching tenant-owned tables returns only A's rows — including list endpoints, exports and reports. Run them in CI against a seeded database with two or three tenants so a missing policy or forgotten WHERE clause fails the build before it ships. Finally, audit quarterly: any table holding tenant data without an enabled RLS policy should be treated as an incident, not a TODO.
Document the model, too: a one-page decision record covering which isolation tier each plan maps to, and what triggers a move between tiers. Six months later, that page is the difference between a ten-minute migration decision and a week-long debate.
Treat tenant context as ambient state threaded through every request, validated at the edge, and enforced at the database. Do that and scaling becomes a capacity problem, not an architecture rewrite.