Multi-tenancy: the SaaS decision you can't easily undo
Cheapest to make on day one, most expensive to change on day four hundred. Here's how to choose, and what breaks at tenant fifty.
Multi-tenancy is the decision that's cheapest to make on day one and most expensive to change on day four hundred. It touches the data model, the query layer, the authentication flow, the backup strategy and the compliance story — which is why retrofitting it into a working single-customer application is one of the more unpleasant projects in software.
We've done that retrofit more than once. Here's what we'd tell anyone choosing before they've written the first migration.
The three models
There are really only three, and the industry's habit of inventing new names for them obscures how few options there are.
| Model | Isolation | Cost per tenant | Operational load |
|---|---|---|---|
| Shared database, tenant column | Logical only | Lowest | Lowest |
| Shared database, schema per tenant | Moderate | Low | Moderate |
| Database per tenant | Strong | Highest | Highest |
For the large majority of B2B SaaS products, a shared database with a tenant column is the right answer, and the objections to it are usually about a risk that's better addressed by enforcement than by architecture.
Why the tenant column wins more often than people expect
The fear is a query that forgets its tenant filter and leaks one customer's data to another. That fear is well-founded — it's the worst bug this architecture can produce. But the answer isn't to give every tenant a database. It's to make the unfiltered query impossible to write.
Two mechanisms, used together:
Enforce at the database
PostgreSQL row-level security means the isolation doesn't depend on application code being correct. Set the tenant on the connection, and the database refuses to return anything else — including from a hand-written query in a migration script at 2am.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Enforce at the query layer
A base query class or repository that requires a tenant context to construct. If a developer has to actively bypass something to write an unscoped query, they won't do it by accident — and code review catches it when they do it deliberately.
Then test it like a security control, because it is one. Write a test that creates two tenants, populates both, and asserts that every model returns only its own rows. Run it in CI. When someone adds a table and forgets the policy, that test fails before the code merges — which is roughly a hundred times cheaper than finding out from a customer.
When you genuinely do need stronger isolation
Database-per-tenant earns its operational cost in a few real situations, and it's worth being honest about which apply to you rather than adopting it defensively:
- Contractual or regulatory separation. Some enterprise and public-sector contracts require it explicitly. Read the clause; don't assume.
- Data residency per customer. One tenant's data must stay in the EU, another's in the UAE. Separate databases in separate regions is the clean answer.
- Per-tenant restore. If you must be able to roll one customer back to Tuesday without touching anyone else, separate databases make that trivial and a shared one makes it painful.
- Genuinely extreme size skew. One tenant generating more data than all others combined will cause query-planning problems for everyone.
Note what isn't on that list: "enterprise customers will ask". They usually ask what your isolation controls are, not what your table layout is. Row-level security, encryption at rest, audit logging and a SOC-style control narrative answer that question fine.
The hybrid that works
Our default for products expecting a mix of self-serve and enterprise customers: shared database with row-level security as the standard tier, plus the ability to move a single tenant to a dedicated database without an application rewrite.
That last part is the design constraint. It means every query goes through a connection resolver that maps tenant to database, even when there's only one database. Adding that on day one costs about a day. Adding it in year two costs about a quarter.
The things that break at tenant fifty
Isolation is the headline decision. These are the ones that quietly cause pain later.
Per-tenant configuration
It starts as one settings table. Then a tenant wants a custom field on a form, then a different approval flow, then their own email templates. Decide early whether configuration is data or code. If it's data, design the schema for it now. If it's code, you have a fork per customer and a maintenance problem you'll be living with for years.
Background jobs
Jobs need tenant context, and they need fairness. One tenant importing two million rows must not starve everyone else's queue. Per-tenant queues, or at minimum weighted fair scheduling, from the start.
Migrations at scale
Fine at five tenants. At five hundred, a migration that locks a large table takes the product down. Get comfortable with the expand–migrate–contract pattern early: add the new column, backfill in batches, switch reads, then drop the old one — never in a single transaction.
Support access
Your support team will need to see a customer's account to help them. If the only way is a database query, you have both a security problem and a productivity problem. Build the impersonation path deliberately: scoped, time-limited, audited, and visible to the customer.
Billing sits closer to the data model than people expect
Usage-based pricing needs a usage event stream from the beginning. It cannot be reconstructed later — you simply won't have the data. Even if you launch on flat per-seat pricing, emit usage events from day one. They cost almost nothing to store and they're the difference between being able to change your pricing model in year two and not.
Keep the billing state and the entitlement state separate, too. What a customer is allowed to do should be readable without calling your payment provider — otherwise their outage becomes your outage.
What we'd do again
Shared database, row-level security enforced in PostgreSQL, a query layer that requires tenant context, a cross-tenant leakage test running in CI, a connection resolver that permits per-tenant databases later, usage events from the first commit, and a scoped audited support impersonation path.
That's roughly a week of extra work at the start of a project. Every one of those items is something we've seen cost months to add afterwards.
The full version, including the migration from a single-customer application without downtime, is in the enterprise SaaS case study.
Working on something like this?
Book a free 30-minute call. It's a working session with an engineer — bring the problem and we'll tell you what it would realistically take to build.
Related reading
All articles →Scoping an MVP that survives contact with real users
"Build an MVP" means two incompatible things depending on who says it. Projects go wrong in the gap.
AIWhere AI automation actually pays: choosing your first process
Most companies pick the process that annoys them most rather than the one that costs the most. Those are rarely the same thing.
AIWhat it takes to run AI agents in production
An agent demo takes an afternoon. An agent you'd let touch a customer record takes considerably longer — and almost none of the extra work is prompt engineering.
Ready to Build Your Next AI Product?
Book a free 30-minute call. We'll go through your requirements and tell you what it would realistically take to build.