Amine Benamrouche · Blog
← Back to writing

Database-per-Tenant vs Shared Database: Choosing the Right SaaS Architecture

Every SaaS product serves more than one customer from the same codebase. The question that decides a surprising amount of your architecture is a narrow one: when customer A and customer B both store data in your system, how do you keep A from ever seeing B’s rows?

There are two popular answers. One shared database with a column that says who owns each row, or a separate database for each customer. Most comparisons stop at “it depends.” This post is about what it actually depends on: the specific engineering costs each approach pushes onto you, and how to tell which cost your product can afford.

I’ll use Laravel for the concrete examples because it’s what I work in, but the tradeoff itself is framework- and language-agnostic. The same decision shows up in Rails, Django, Spring, and raw SQL.

It’s a spectrum, not a binary

Before the two options, it helps to see that “isolation” isn’t one switch. It’s a dial, usually described as running from pool (everything shared) to silo (everything separated):

POOL ──────────────────────────────────────────────► SILO
cheaper, denser more isolated, pricier
Shared DB ──► Shared DB ──► Database ──► Separate
shared schema schema per per tenant infra per
(tenant_id col) tenant tenant

The further right you go, the stronger the isolation and the higher the per-tenant cost. The two options this post compares, shared schema and database-per-tenant, are the two points on this dial that most teams actually choose between. The far ends (schema-per-tenant, or fully separate infrastructure) are variations on the same tradeoff, not different tradeoffs.

The key insight is what the dial is really measuring: whether tenant isolation is enforced by your application code or by your infrastructure. Everything below follows from that one distinction.

Approach 1: Shared database, isolation in code

Every tenant’s rows live in the same tables. Each row carries a column (tenant_id, organization_id, sometimes just user_id) naming its owner. Isolation means: every query is filtered by that column, without exception.

-- One table, every tenant's data interleaved
SELECT * FROM invoices WHERE tenant_id = ? AND status = 'overdue';

The danger is the words “without exception.” Miss the filter once, in a raw query, a report, a background job, and you’ve shown one customer another customer’s data. So mature codebases don’t rely on developers remembering. They make the filter automatic and hard to bypass.

In Laravel that’s a global scope: a model-level rule that silently appends the tenant_id condition to every query, so a plain Invoice::all() already means “this tenant’s invoices.” A trait fills the column in on insert so you can’t forget it on the way in either:

trait BelongsToTenant
{
protected static function booted(): void
{
static::creating(fn ($model) => $model->tenant_id ??= currentTenant()->id);
static::addGlobalScope('tenant', fn ($query) =>
$query->where('tenant_id', currentTenant()->id)
);
}
}

That pattern (a column, an automatic filter on read, an automatic stamp on write) is the whole idea, in any framework. A second layer, authorization policies, is worth adding so that a request guessing another tenant’s record by ID gets an explicit 403 rather than silently falling out of a filtered list.

What about row-level security?

This is the natural objection to everything above: if the risk is a forgotten WHERE, why not make the database enforce the tenant filter so a forgotten clause can’t leak anything? That’s exactly what row-level security (RLS) does, and it’s the strongest option most teams never reach for.

In PostgreSQL you enable it on the table and attach a policy that reads the current tenant from a session variable:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::int);

Then the app sets that variable once per request, and from that point every query against invoices is filtered by the database, whether or not the application remembered to add the condition:

SET app.tenant_id = '42'; -- set at the start of the request
SELECT * FROM invoices; -- the database only returns tenant 42's rows

A forgotten filter, a raw query, a reporting job that skipped the scope: none of them can leak across tenants anymore, because the enforcement no longer lives in the application code. This genuinely moves shared-schema isolation from “code-enforced” toward “infrastructure-enforced,” and it narrows the blast-radius gap with database-per-tenant more than anything else on this list.

It isn’t free, though, and the caveats are the reason it stays underused:

  • The session variable has to be set correctly on every connection. This is the new footgun. With a connection pool that hands out reused connections, a variable left set from a previous request is its own cross-tenant leak, so it has to be scoped to the transaction (SET LOCAL) or reset every time.
  • Table owners and superusers bypass RLS by default. The role your app connects as must be a non-owning role, or you have to FORCE ROW LEVEL SECURITY, or the policy quietly does nothing for that role.
  • Every query carries the policy predicate, which has real planner and performance implications (usually fine with an index on tenant_id, but it’s not zero).
  • It’s a Postgres / SQL Server feature, not a universal one. MySQL has no native RLS, and since the Laravel default stack is MySQL, this is an option you opt into with a different database, not something the framework gives you. Laravel won’t wire the session variable for you either; you’d set it in a connection listener or request middleware.

Where it fits: RLS lets you keep the density and operational simplicity of a single shared database while getting isolation the database enforces instead of trusting every query to behave. If you’re on Postgres and isolation matters, it’s often a better answer than jumping straight to a database per tenant.

Approach 2: Database per tenant, isolation in infrastructure

Each tenant gets its own database. The request lifecycle gains one step at the front:

  1. A request arrives, say for acme.app.com.
  2. The app resolves which tenant that is, typically from the subdomain, a custom domain, or a token.
  3. It points the database connection at that tenant’s database (acme_db).
  4. Everything after that is a plain single-tenant app: ordinary queries, no filtering.

There is no tenant_id column anywhere, because there’s nothing to filter against: the connection is the boundary.

A missing WHERE clause can no longer leak across tenants, because the other tenant’s data isn’t reachable on this connection at all. That’s the appeal in one sentence: the isolation holds even when your code has a bug.

In Laravel the common tool is stancl/tenancy, which handles tenant resolution, creating and migrating each tenant’s database on signup, and, crucially, making cache, queues, and file storage tenant-aware too, not just the database connection. (A lighter package, spatie/laravel-multitenancy, only identifies the current tenant and leaves the data-separation logic to you.) But again, the mechanism is universal: resolve tenant, swap connection, run ordinary queries.

The costs that actually decide it

Here’s where most comparisons wave their hands. The choice is a trade of which problems you’d rather have. Laid out directly:

DimensionShared DBDB per tenant
Bug blast radiusLeaks all tenantsContained to one
Isolation byApp codeInfrastructure
MigrationsRun onceRun × N databases
Cost per tenantNear zeroFixed overhead
Noisy neighborsShared contentionNaturally isolated
Cross-tenant queriesOne queryFan-out across N
Deleting a tenantFiltered deletesDrop the database
ConnectionsOne poolPool-per-DB limits

Four of these are worth more than a table cell.

Blast radius is the real headline

This is the dimension people underweight. In a shared database with isolation enforced only in code, the worst-case failure is catastrophic and total: a single forgotten filter in a reporting endpoint can expose every customer’s data at once. (This is exactly the gap row-level security closes, which is why it’s worth the setup on Postgres.) In database-per-tenant, the same class of bug is contained: the data physically isn’t on the connection to leak. You’re not buying convenience here, you’re buying a smaller worst case. For some products (health records, financial data, anything with a breach-notification law attached) that smaller worst case is the entire decision.

Operational overhead compounds with tenant count

A shared database has one schema to migrate, one thing to back up, one set of metrics to watch. Database-per-tenant multiplies all of that by your tenant count. A migration isn’t one statement anymore. It’s the same statement run against every tenant database in turn, and a bad one can half-succeed across hundreds of them before you notice. Backups, monitoring, and “is every database actually on the latest schema?” all become fleet problems instead of single-object problems.

Resource density is a cost, not a footnote

Every database has fixed overhead (memory, a connection footprint, backup storage, a minimum size) whether it holds one row or a million. A shared database amortizes that across all tenants. Per-tenant databases pay it per tenant. If your tenants are thousands of small, cheap-to-serve users, that overhead can dominate your infrastructure bill for no isolation benefit those users actually need. This is why the shared model dominates high-volume, low-price-per-tenant B2C products: it’s often the only approach whose unit economics even work.

Connection management is the sharp edge of this. A connection pool to one database is routine. You cannot hold an open pool to ten thousand tenant databases at once; the resolution has to connect, work, and release, and getting that wrong is a classic way to exhaust a database server’s connection limit under load.

Noisy neighbors and uneven growth

In a shared database, tenants compete for the same CPU, the same I/O, the same query planner cache. One customer running an enormous report, or simply growing ten times faster than everyone else, degrades the experience for all of them. You fight this with indexing, read replicas, and query limits, but you can’t fully eliminate it; the contention is structural.

Database-per-tenant turns that same uneven growth into an option instead of a problem: because each tenant is already a separate database, a tenant that outgrows the rest can be moved onto dedicated hardware without touching anyone else’s setup. But that option isn’t free; it’s a cost paid in a different currency. The moment one tenant lives somewhere different, your infrastructure is heterogeneous: migrations, deploys, backups, and monitoring now have to account for tenants in different places, and “where does this tenant actually live?” becomes a question your system has to answer correctly on every request. You’ve traded a performance-contention problem for a fleet-management problem. Which of those two your team is better equipped to carry is a real part of the decision.

A framework for choosing

Strip away the specifics and it comes down to four questions:

  1. Is physical separation a requirement, not a preference? If a customer contract, a regulator, or a data-residency law says one tenant’s data must be physically separate from another’s, that decides it: reach for database-per-tenant (or further right on the spectrum) regardless of everything else below. This is the single most common reason real systems go this route, and notably it’s a business requirement, not a technical one.
  2. What’s the shape of your tenant base? A handful of large, high-value customers makes the per-tenant overhead easy to justify. Thousands of small, low-price tenants makes it ruinous; the density argument wins decisively.
  3. How central is cross-tenant data to the product? Platform-wide analytics, usage-based billing, admin dashboards that span every customer: all trivial in a shared database, all a genuine engineering problem across separate ones. If this is core to what you’re building, weigh it heavily.
  4. What failure can you least afford? This is the one to sit with. A shared database’s worst day is a cross-tenant data leak. A per-tenant setup’s worst day is an operational one: a migration that corrupted a subset of databases, a tenant stuck on an old schema. Choose the architecture whose worst-case failure your product and team can actually survive.

The default, and when to leave it

The accumulated advice across the backend community lands in a consistent place, and the cost breakdown explains why: start with a shared database and column-based scoping, and move to database-per-tenant only when something concrete forces you there.

“Something concrete” is almost never raw scale on its own; a well-indexed shared database goes a very long way. It’s a requirement: a signed contract demanding isolation, a regulation about where data physically sits, a single tenant so large it needs its own hardware. Absent one of those, the shared model gives you less operational surface area, better unit economics, and trivial cross-tenant queries, and you keep the option to carve a demanding tenant out later, which is genuinely easier than migrating a whole silo’d fleet back into one database.

The mistake isn’t picking either one. It’s picking the more isolated, more expensive architecture by default because it feels more rigorous, and then carrying its operational weight for an isolation guarantee none of your customers ever actually asked for.