There is a specific email we get two or three times a year. It reads roughly like this:
"We have 400 customers and things are working, but our biggest prospect is asking for data residency in Germany and we have no idea how to give it to them without rebuilding everything."
That email is a tenancy architecture problem, and it was created eighteen months before it was discovered. This post is about making those decisions deliberately instead of accidentally.
The market context matters here: over 70% of modern SaaS vendors now run some form of multi-tenancy, and the appeal is straightforward. Multi-tenant infrastructure keeps base costs nearly flat as you add customers. Adding your 1,000th customer costs a fraction of what your 10th did. That is the whole business case.
But "multi-tenant" is not one thing. It is at least three, and they are not equally reversible.
The three models, honestly assessed
Model 1: Shared schema, tenant ID column
Every tenant's rows live in the same tables. A tenant_id column separates them.
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id, created_at DESC);Where it wins: cheapest to run by a wide margin, simplest to operate, one migration applies to everyone, and analytics across your whole customer base is a single query. For a product with thousands of small customers this is almost always right.
Where it hurts: one missing WHERE tenant_id = ? is a data breach. Not a bug, a breach. Noisy neighbours are real, backup and restore for a single tenant is painful, and data residency is effectively impossible.
The mitigation that actually works: do not rely on your ORM to remember the filter. Push it into the database with row-level security.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);Then set app.tenant_id once per request in your connection middleware. Now a forgotten filter returns zero rows instead of everyone's invoices. This is the single highest-value hour of work in a shared-schema product and a surprising number of teams skip it.
Model 2: Schema per tenant
Same database, separate PostgreSQL schema per tenant. tenant_acme.invoices, tenant_globex.invoices.
Where it wins: meaningfully stronger isolation, per-tenant backup and restore becomes practical, and you can let a specific tenant have a slightly different shape if you truly must.
Where it hurts: migrations now loop over N schemas, and at a few thousand schemas that loop becomes an operational event. Connection pooling gets more complicated. Cross-tenant reporting requires unions.
Honest take: this is the awkward middle. Good for products with tens to low hundreds of mid-sized customers. Painful above roughly a thousand.
Model 3: Database per tenant
Full separation. Sometimes a full stack per tenant.
Where it wins: isolation is genuine rather than logical, data residency is trivially solvable, a noisy tenant cannot touch anyone else, and enterprise security questionnaires get much easier to answer.
Where it hurts: cost scales linearly with customers, which destroys the core economic argument for SaaS. Operations become a serious discipline. You will need real tooling for provisioning, migration orchestration, and monitoring across N databases.
Honest take: correct for enterprise products with a small number of large, compliance-heavy customers. Wrong for anything self-serve.
The decision framework
Here is the shortcut I use in architecture calls. Answer these four questions before you write a line of schema.
1. What is your expected customer count at 1,000 and at 10,000 in ARR terms? Ten enterprise customers at $200k each and ten thousand SMBs at $200 each are the same revenue and completely different architectures.
2. Will any customer ever ask where their data physically lives? If you are selling into EU healthcare, finance, or government, the answer is yes and it will arrive as a deal-blocker in a procurement questionnaire. Plan for it now.
3. Can one tenant's usage plausibly be 100x another's? If yes, shared everything will eventually mean one customer degrading service for everyone. You need either resource governance or physical separation.
4. Do you need cross-tenant analytics as a product feature? Benchmarking features ("you are in the top 20% of firms your size") are far easier on shared schema and require a separate warehouse pipeline otherwise.
The hybrid nobody talks about but everybody eventually builds
Most successful SaaS products do not stay on one model. They land here:
Shared schema with RLS for the self-serve and mid-market tier, which is 95% of tenants and 40% of revenue
Dedicated database for enterprise tenants who pay for it, which is 5% of tenants and 60% of revenue
One application codebase that resolves the right connection at request time based on tenant metadata
The critical design decision that makes this possible is a tenant resolution layer built in from day one, even when every tenant lives in the same place.
// Build this on day one even if every tenant resolves to the same DB.
// Retrofitting it later means touching every data access path you own.
async function resolveTenantConnection(tenantId: string) {
const tenant = await tenantRegistry.get(tenantId)
switch (tenant.isolationTier) {
case 'dedicated':
return connectionPool.for(tenant.databaseUrl)
case 'shared':
default:
const conn = await connectionPool.shared()
await conn.query('SET app.tenant_id = $1', [tenantId])
return conn
}
}Twenty lines. Write them in week one. They are the difference between "we can offer you a dedicated instance, that will take three weeks" and "we would need to rebuild our data layer."
Four failure modes we see repeatedly
The forgotten filter
Covered above. Fix it with row-level security, not code review discipline. Discipline fails at 3am.
Migrations that lock the world
A schema migration that takes a table lock is an outage for every tenant simultaneously. Use expand-and-contract: add the new column, backfill in batches, dual-write, switch reads, drop the old column. Four deploys instead of one, and zero downtime. In Postgres, always create indexes CONCURRENTLY.
No per-tenant cost attribution
This is the quiet margin killer. If you cannot answer "what does this customer cost us to serve," you cannot price correctly and you cannot spot the customer on your $49 plan generating $2,000 of infrastructure spend. Tag every resource, log tenant ID on every expensive operation, and build the cost report before you need it. FinOps discipline is not optional at any real scale.
Observability without tenant context
When a customer says "it is slow," you need to filter your traces by that tenant in seconds. If tenant ID is not a first-class dimension in your logs, metrics and traces, every support escalation becomes an archaeology project. Add it to your logging context in the same middleware that sets the tenant connection.
The security layer sits on top of all of this
Tenancy isolation is only one dimension. Your API surface is another, and it is the one attackers actually probe. Broken object level authorization, where an endpoint returns object /api/invoices/12345 without checking whether the caller's tenant owns it, remains the most exploited API vulnerability in the field.
We break that down properly in the OWASP API Top 10 with actual fixes. If you are building multi-tenant, read that one next. The two topics are the same problem viewed from different layers.
What we would do on a greenfield SaaS in 2026
For a product targeting SMB and mid-market, defaulting to self-serve signup:
Shared schema, Postgres, row-level security enabled from the first migration. Not added later. From the first migration.
Tenant resolution layer abstracted from day one, even though it only ever returns the shared pool at first.
Tenant ID in every log line, metric label and trace span.
Per-tenant cost attribution wired up before you have 50 customers, while it is still easy.
A documented path to dedicated instances that you have actually tested once with a throwaway tenant, so the first time you do it is not for a paying enterprise customer.
That gets you to roughly ten thousand tenants without an architectural rewrite, and it leaves the enterprise door open.
The honest closing thought
The most expensive architecture mistakes are not the wrong choices. They are the choices made without knowing a choice was being made.
If you are somewhere on this journey and the ground is starting to feel uneven, send us the shape of the problem. We do architecture reviews as standalone engagements, and quite often the outcome is "you are fine, here are three things to fix" rather than a rebuild proposal.
Common questions
What is multi-tenant SaaS architecture?
An architecture where one application instance serves many customers, called tenants, while keeping each tenant's data separated. There are three common models: a shared schema with a tenant ID column on every row, a separate database schema per tenant, and a fully separate database per tenant. They differ mainly in cost, isolation strength, and how easily you can offer data residency.
How do you build a multi-tenant SaaS application?
Start by choosing an isolation model based on your expected customer profile, then build a tenant resolution layer on day one even if every tenant resolves to the same database. For most products, a shared schema with PostgreSQL row-level security enabled from the first migration is the right default. Add tenant ID to every log line, metric and trace span, and set up per-tenant cost attribution before you reach fifty customers.
Which multi-tenancy model is cheapest?
Shared schema, by a wide margin. Base infrastructure cost stays nearly flat as you add customers, which is why adding your 1,000th customer costs a fraction of adding your 10th. Multi-tenant models cut infrastructure cost by up to 50% against single-tenant. Database-per-tenant is the most expensive, because cost scales linearly with customer count.
How do you prevent one tenant seeing another tenant's data?
Do not rely on application code remembering to filter by tenant ID, because that fails eventually. Enforce it in the database with row-level security, so a query that forgets the filter returns zero rows rather than everyone's data. Set the tenant context once per request in connection middleware, and return 404 rather than 403 for objects a tenant does not own, so attackers cannot enumerate your ID space.
Keep reading