How We Structured a Multi-Tenant SaaS Database: 3 Approaches

Multi-tenant SaaS database architecture is one of those decisions you make in an afternoon and live with for years. On our first B2B SaaS build, we picked a model fast because we wanted to ship features, not argue about schemas. Eight months later we were rewriting a quarter of the data-access layer because the model we chose couldn't answer an enterprise prospect's questions about data isolation. The decision that took us a day to make took a month to undo.
This post is the afternoon of thinking we skipped — all three approaches, the real trade-offs, and the exact questions that tell you which one you actually need.
The short answer: Multi-tenant SaaS database architecture has three main models — shared database/shared schema (one set of tables with a tenant_id column), schema-per-tenant (one database, a separate schema per tenant), and database-per-tenant (a dedicated database per customer). They trade isolation against cost and operational complexity. For most SaaS, start with shared schema plus Row-Level Security and only isolate further when a specific client or compliance rule forces it.
What Multi-Tenant SaaS Database Architecture Actually Means
A "tenant" is one customer of your SaaS — usually a company, with its own users, settings, and data. Multi-tenancy is how a single running application keeps all those tenants' data both together (so you maintain one codebase and one deployment) and apart (so Tenant A never sees Tenant B's invoices).
That last part is the whole job. Everything else in multi-tenant SaaS database architecture is a different answer to the same question: where does the boundary between tenants live?
Here's what multi-tenancy is not. It is not "we'll add a customer column later." It is not single-tenant — spinning up a separate full application install per customer, which is a deployment problem, not a database one. And it is not solved by your ORM being careful. ORMs forget. People forget. The boundary has to live somewhere that doesn't.
This matters more than it used to. There are over 30,000 SaaS companies competing for the same buyers (industry trackers, 2025), and the ones that survive are the ones that stay maintainable. The data layer is where "maintainable" is won or lost.
The 3 Multi-Tenant Database Approaches at a Glance
Every multi-tenancy model sits somewhere on a spectrum between two extremes that AWS and others usefully call the pool model (everyone shares everything) and the silo model (everyone gets their own). The middle ground — a separate schema per tenant — is sometimes called the bridge model.

As you move from pool to silo, isolation goes up and so does cost and operational overhead. There is no free lunch — there is only "which problem would you rather have." Here are the three, in order of increasing isolation.
Approach 1: Shared Database, Shared Schema (the tenant_id Model)
This is the pool model. Every tenant's data lives in the same tables, in the same database. You separate tenants with a tenant_id column on every table that holds tenant data.
1CREATE TABLE projects (
2 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
3 tenant_id BIGINT NOT NULL REFERENCES tenants(id),
4 name TEXT NOT NULL,
5 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
6);
7
8-- The index that makes or breaks this model:
9CREATE INDEX idx_projects_tenant_id ON projects (tenant_id);Every query then carries the tenant filter:
1SELECT * FROM projects WHERE tenant_id = $1 AND id = $2;Why it's good: It's the cheapest and fastest to ship. One database to back up, one schema to migrate, one connection pool to tune. A single well-tuned PostgreSQL instance handles far more tenants than most founders believe.
Where it bites: Two places. First, the noisy neighbour — one tenant running a monster report can degrade performance for everyone, because they share the same compute. Second, and more dangerous, the cross-tenant leak: the day someone writes SELECT * FROM projects WHERE id = $1 and forgets the AND tenant_id = $1, you've served Tenant A's data to Tenant B. That is not a hypothetical; it is the single most common multi-tenant bug, and it is exactly why Approach 1 needs the safety net in the Row-Level Security section below.
For most early B2B SaaS, this is still the right starting point — if you enforce the boundary in the database instead of trusting every developer to remember it.
Approach 2: Schema-per-Tenant in PostgreSQL
This is the bridge model. One database, but each tenant gets its own PostgreSQL schema — a named namespace of tables. Tenant acme gets an acme.projects table; tenant globex gets globex.projects. Same database, separate table sets.
1-- Provision a new tenant on signup:
2CREATE SCHEMA tenant_acme;
3
4-- Then create the standard table set inside it:
5CREATE TABLE tenant_acme.projects (
6 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
7 name TEXT NOT NULL,
8 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
9);At request time you point the connection at the right schema with search_path. In a NestJS app, that's middleware that runs once per request:
1// Set the active schema per request based on the resolved tenant
2@Injectable()
3export class TenantSchemaMiddleware implements NestMiddleware {
4 constructor(private readonly dataSource: DataSource) {}
5
6 async use(req: Request, _res: Response, next: NextFunction) {
7 const schema = resolveTenantSchema(req); // e.g. "tenant_acme"
8 await this.dataSource.query(`SET search_path TO "${schema}"`);
9 next();
10 }
11}
Why it's good: Stronger isolation than shared schema, and you can customize one tenant's tables without touching anyone else's. A single tenant's data is easy to export or drop.
Where it bites: Migrations. A schema change is no longer one ALTER TABLE — it's one per schema, run across every tenant, and it has to not half-fail in the middle. At 20 tenants that's an afternoon; at 2,000 it's an automated pipeline you have to build and trust. Connection management gets fiddlier too, because search_path is connection state and you're sharing pooled connections. This model looks tidy in week one and gets quietly expensive at scale. (Microsoft's multitenant SaaS patterns guide walks through the same operational-complexity curve in detail.)
Approach 3: Database-per-Tenant
This is the silo model. Each tenant gets a completely separate database. Maximum isolation: separate storage, separate compute, separate backups, separate blast radius.

Why it's good: It's the answer enterprise security reviews want to hear. One tenant can never see another's data because the data isn't even in the same place. Per-tenant restore is trivial — you restore one small database, not pick one tenant out of a shared one. Customization and per-tenant performance tuning are straightforward. For regulated industries, this is often non-negotiable.
Where it bites: Cost and operations, hard. Connection pooling is the first wall you hit — a pool per database doesn't scale the way a single shared pool does, and you'll be reaching for a proxy like PgBouncer sooner than you'd like. Every schema migration now runs across hundreds or thousands of databases. Provisioning, monitoring, and disaster recovery all multiply by your tenant count.
Here's the opinion I'll plant my flag on: database-per-tenant feels safe and is usually premature. For 99% of early SaaS, reaching for it on day one because separation "feels more secure" is procrastination with a whiteboard. The leak risk in a shared model isn't the schema — it's the forgotten WHERE tenant_id, and there's a much cheaper fix for that.
Row-Level Security: The Safety Net That Makes Shared Schema Safe

This is the part most "shared vs separate" comparisons skip, and it's the most important paragraph in this post. PostgreSQL Row-Level Security (RLS) moves your tenant filter out of application code — where it can be forgotten — and down into the database, where it can't.
You enable it on the table, then write a policy:
1ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
2
3CREATE POLICY tenant_isolation ON projects
4 USING (tenant_id = current_setting('app.current_tenant')::bigint);Now you set the tenant context once per request, and the database enforces it on every query — SELECT, INSERT, UPDATE, DELETE:
1// Once per request, after you've authenticated the tenant:
2await dataSource.query(`SET app.current_tenant = '${tenantId}'`);After that, SELECT * FROM projects returns only the current tenant's rows. The forgotten WHERE tenant_id clause stops being a data breach and becomes a non-event, because Postgres applies the policy whether your developer remembered to or not. The full behaviour — permissive vs restrictive policies, how superusers bypass it — is in the official PostgreSQL Row Security docs, and it's worth reading before you ship.
Why does this matter so much? Because the average data breach now costs $4.88 million (IBM Cost of a Data Breach 2024), and a cross-tenant leak in a B2B product is both a breach and a "your competitor saw your data" conversation. RLS turns tenant isolation from a convention you hope holds into a guarantee the database makes. It is the reason shared-schema is safe enough for serious products.
The Multi-Tenant Database Decision Matrix
There is no universally correct model — there's the one that fits your tenant count, data sensitivity, and team size. Use this matrix the way we now do before any SaaS build.
| Factor | Shared schema (pool) | Schema-per-tenant (bridge) | Database-per-tenant (silo) |
|---|---|---|---|
| Tenant isolation | Logical (RLS) | Stronger | Physical / strongest |
| Per-tenant cost | Lowest | Low–medium | Highest |
| Scale ceiling | Very high | Medium | Limited by ops |
| Schema migrations | One, simple | One per schema | One per database |
| Per-tenant restore | Hardest | Medium | Trivial |
| Customization per tenant | Hard | Easy | Easy |
| Compliance fit | Good (with RLS) | Good | Best |
| Best for | Most early B2B SaaS | Mid-size, customization needs | Regulated / enterprise tenants |
Ask three questions in order: How sensitive is the data, and what will a security review demand? How many tenants will I realistically have in 18 months? How big is the team that has to operate this at 3am? The honest answers usually point at shared schema with RLS, with a documented escape hatch to isolate individual enterprise tenants later.
What We Chose — and Why

On the build I mentioned at the top, we'd reached for separate schemas early because it felt more organized — and then spent month eight discovering that our migration story didn't scale and our isolation guarantees were weaker than we'd assumed. When we rebuilt the data layer, we went with shared database, shared schema, with Row-Level Security enforcing the tenant boundary — and a deliberate plan to move any single enterprise tenant to its own database if a contract ever required it.
That hybrid stance is the one we now bring to client work by default. Pool by design, silo by exception. It's cheaper to run, faster to ship, and — with RLS doing the enforcement — it passed the enterprise security review that started this whole story. We didn't choose it because it's clever. We chose it because it's the version we can debug at 3am without paging the whole team.
4 Mistakes That Cause Tenant Data Leaks
Most cross-tenant leaks aren't exotic. They're the same four mistakes, over and over.
- Filtering only in application code. If tenant isolation lives entirely in your
WHEREclauses, one forgotten clause is one breach. Enforce it in the database with RLS. - Trusting the ORM to scope everything. ORMs are happy to run the query you wrote, tenant filter or not. A raw query, a reporting endpoint, or a clever join is all it takes.
- Leaking IDs across tenants. Sequential, global IDs let a curious user increment a number in a URL and probe other tenants. Scope lookups by tenant, and don't expose raw global IDs.
- Forgetting the admin and migration paths. Background jobs, admin tools, and migration scripts often run as a superuser that bypasses RLS. Test those paths explicitly — they're where the boundary quietly disappears.
The fix for all four is the same principle: put the boundary somewhere a tired human can't accidentally remove it.
Final Verdict on Multi-Tenant SaaS Database Architecture
If you take one thing from this: pick the model you can operate, not the one that looks safest on a slide. For most B2B SaaS, that's shared schema with Row-Level Security, with database-per-tenant held in reserve for the enterprise client who genuinely needs it. Spend the afternoon on this decision now, because the alternative is spending a month undoing it later — ask me how I know.
If you're staring at this decision for your own product and genuinely can't tell which way to jump, that's the kind of thing we argue about for a living. Tell us about your build and we'll talk it through, or browse more engineering guides and read how we work first. Either way — pick the boring database. Future-you, holding a pager, will send a thank-you note.
Frequently Asked Questions
Multi-tenant SaaS database architecture is how you map many customers' (tenants') data to storage in a single application. The three main models are shared database/shared schema (one set of tables with a tenant_id column), schema-per-tenant (one database, a separate schema per tenant), and database-per-tenant (a dedicated database per tenant). The choice trades isolation against cost and operational complexity.
For most early-stage B2B SaaS, start with shared database/shared schema plus PostgreSQL Row-Level Security. It is the cheapest to run and fastest to ship, and RLS enforces tenant isolation in the database itself. Move a specific tenant to database-per-tenant only when a compliance contract, a noisy-neighbour problem, or heavy per-tenant customization actually demands it — not because separation 'feels safer'.
Yes, when you enforce isolation in the database rather than hoping every query remembers a WHERE tenant_id clause. PostgreSQL Row-Level Security adds a policy the database applies to every query automatically, so a forgotten filter can't leak one tenant's rows to another. Shared-schema is used by large, regulated SaaS products; the risk is sloppy filtering, not the model itself.
Shared schema stores every tenant's data in the same tables, separated by a tenant_id column — cheapest and simplest, but isolation is logical, not physical. Database-per-tenant gives each tenant a completely separate database — the strongest isolation and easiest per-tenant restore or customization, but far higher cost, connection-pool pressure, and the pain of running migrations across hundreds of databases.
Row-Level Security (RLS) lets you attach a policy to a table so PostgreSQL automatically restricts which rows a query can see based on a session variable like the current tenant ID. You set the tenant context once per request, and the database filters every SELECT, INSERT, UPDATE, and DELETE for you. It turns tenant isolation from an application convention into a database guarantee.
