Back to writing
Laravel · Web Application

You probably don't need a database per tenant

/ 9 min read

Database-per-tenant is the default people reach for in multitenancy, and it's usually the wrong one. Here's the full spectrum from separate instances down to a discriminator column, what each one costs you per tenant forever, and why Postgres plus row-level security is enough for most apps.

Multitenancy data isolation spectrum, from a database per tenant down to a shared table with a tenant_id column and row-level security
On this page

Most teams building a multi-tenant app reach for the same thing on day one: a database per tenant. It feels safe. Each customer gets their own box, nothing can leak, done. Then six months in you're writing a migration pipeline that has to run cleanly across 400 databases, and when it breaks on tenant 217 you spend an afternoon working out which ones took the change and which didn't.

Here's the shorter version of this whole post: you probably don't need it. A single Postgres database with a tenant_id column and row-level security will give you the isolation you're actually after, without the bill that separation charges you every single day.

This is a rewrite and expansion of Ollie Read's excellent piece on the same topic, aimed at the Laravel and general web crowd. Credit where it's due. I'm adding the angles I hit most often on client work.

Isolation is not separation

The entire "database per tenant is safest" belief rests on mixing up two different things.

Isolation is about security and correctness. Tenant A cannot see Tenant B. Full stop.

Separation is about storage. Tenant A's data physically sits in a different place from Tenant B's.

You can get isolation through separation. You can also get isolation with zero separation. That second sentence is the one people forget, and it's why they overbuild. Once you see isolation and separation as two separate dials, the whole decision gets easier.

The spectrum, heaviest to lightest

Think of the options as a line. At one end: heavy, fully separated, maximum isolation, and a fat operational bill. At the other: light, shared, cheap, and isolation you enforce yourself. Developers routinely pick from the heavy end to buy a guarantee that the light end would have given them anyway.

Here's the run, top to bottom.

Separate instance

Every tenant gets its own database server. Own credentials, own connection, own everything. This is the real "database per tenant" if you ask me, and it's the heaviest thing on the board.

Isolation is total. Separate processes, separate resources, separate networking. No data leaks and no tenant can even slow another one down.

The cost is brutal though. You now have N servers to provision, patch, monitor, scale, and back up. Migrations run N times across N hosts. Cross-tenant reporting is basically impossible without building a whole aggregation pipeline, and you need somewhere safe to keep N sets of credentials.

Reach for this only when the law makes you. Different regions for data residency, or one genuinely enormous tenant you want physically walled off so it can't hurt anyone else.

Separate database, same instance

Same server, one database per tenant. This is what most people actually mean by "database per tenant," and it's lighter than separate instances, but still heavy.

No resource isolation here, so a noisy tenant can drag on the others. Data isolation depends entirely on your engine:

  • PostgreSQL: databases are isolated by architecture. Cross-database queries aren't a native thing unless you deliberately set them up. Best engine for this approach.
  • MySQL/MariaDB: isolation is privilege-based only. Share one user across tenants and there's no real isolation at all, because {db}.{table} cross-database queries just work. It's only as good as your permissions.
  • SQLite: one file per tenant. Feels like separation, but it's filesystem-level, so any process with the file can open it.

Migrations still run N times. The nasty part on Postgres is connections: pools key on a database and user pair, so you can't share a pool across tenants. More tenants means more connections, and you march toward the instance's connection ceiling. MySQL can swap databases on a live connection so pools are shareable, but only if you share a user, which guts the isolation you were paying for.

Small number of known, controlled tenants that isn't going to explode? Fine. Anything with real growth hits that connection wall fast.

Schemas or prefixes

Namespacing. All tenants share one database, split by a namespace. On Postgres that namespace is a schema. On MySQL, MariaDB, and most other engines it's a prefix on the table name.

  • PostgreSQL: schemas are first-class. A single user sets search_path on the connection and gets scoped to its schema. You can go further with a user per tenant granted access only to its own schema, but now you're back in connection-pool pain. If you run a transaction-mode pooler, watch for cross-tenant leakage.
  • MySQL/MariaDB: schema is just a synonym for database here, so you're stuck with table prefixes. Nothing enforces anything. The table name is a raw string in your query. Weakest option on the board.
  • SQLite: same story, string-enforced only.

Migrations still run N times. The upside over the previous approaches is that cross-tenant queries and analytics get much easier. Really this is a Postgres-only play at the schema level, good for tens to low hundreds of tenants. On MySQL, skip it and pay the small extra cost of separate databases with users per tenant.

Partitioned tables

The engine splits one table into multiple physical storage tables using a partition key. On its own, without Postgres and RLS, this adds nothing to isolation beyond what a plain discriminator column gives you. It's a scaling tool, not an isolation tool.

The win: migrations run once, like a normal single-tenant app, and the per-tenant connection problem disappears. The catch is schema design. Your partition key has to be part of the primary key and every unique key, which fights ORMs with weak composite-key support. Eloquent, Laravel's ORM, is exactly that kind of ORM, so plan for friction.

MySQL and MariaDB add more pain: partitioned tables can't hold foreign keys, so you lose database-level referential integrity, and there's a hard cap of 8192 partitions you cannot work around. Postgres has the best support by a distance, including moving a hot tenant to faster storage.

Use it when you're already on one of the lighter approaches below but a specific table has grown big enough that physical layout matters for speed or per-tenant backup. It's a scaling decision.

Discriminator column

The lightest, cheapest, least isolated, and the one most apps should actually use. Every table with tenant data gets a column, call it tenant_id (or account_id, organization_id, company_id, whatever fits your domain). Every query filters on it. It works on any engine, foreign keys or not.

Isolation is convention-based. Nothing in the database stops you. Miss the WHERE clause and you leak. That sounds scary until you remember you're not sprinkling where('tenant_id', ...) across a thousand queries by hand. You centralise it once. In Laravel that's a global scope:

// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenantId = app('currentTenant')?->id) {
                $builder->where($builder->getModel()->getTable().'.tenant_id', $tenantId);
            }
        });

        static::creating(function ($model) {
            if (! $model->tenant_id && $tenant = app('currentTenant')) {
                $model->tenant_id = $tenant->id;
            }
        });
    }
}

Every model that uses the trait now filters and stamps automatically. Migrations run once. No connection-pool grief. Onboarding a tenant is one row in the tenants table, which you were creating anyway.

The honest weakness: enforcement lives in your app, spread across the codebase. Centralise it and it's fine, but it stays a little fragile, because "fine" depends on nobody ever bypassing the scope. This is the pattern Shopify used, sharded on top for scale, at a size almost none of us will ever see. And the fragility has a proper fix, which is the next section.

Row-level security closes the case

Row-level security (RLS) isn't a multitenancy approach, it's a Postgres feature you bolt onto the discriminator column. It moves the tenant filter out of your application and into a database policy. Now the database refuses to hand over another tenant's rows, whether or not your code remembered to ask nicely.

You set the tenant on the connection with a session variable, and Postgres does the rest. Sketch:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.current_tenant')::bigint);

Then per request, inside a transaction:

SET LOCAL app.current_tenant = '217';

Three traps worth knowing before you ship it:

  • Table owners bypass RLS by default. Fix it with FORCE ROW LEVEL SECURITY, and note superusers and anyone with BYPASSRLS still skip it regardless.
  • Migrations have to run as a user that isn't subject to the policies, or they'll fail. The clean setup is two users: an owner that runs migrations and isn't subject to RLS, and an application user that is subject to RLS and runs every normal query.
  • Transaction-mode poolers can leak. Because the tenant lives in a session variable, a connection handed back to the pool still carries it. Use SET LOCAL inside a transaction so it resets when the transaction ends, and when you write the policy, tell it to error (not return empty) when the variable is unset. An empty result looks identical to "no rows," which is how a silent leak hides.

Postgres plus a discriminator column plus RLS buys you nearly all the isolation of separation, at nearly the operational cost of a shared table. That's the sweet spot for most SaaS.

When separation is genuinely worth it

I'm clearly biased toward the light end, but some things you cannot get cheaply without separating. When these are real requirements, pay the cost and separate:

  • Regulation: rules that force physical isolation, data in a specific jurisdiction, or per-tenant encryption for cryptographic erasure.
  • Per-tenant backup and point-in-time recovery: technically doable on any approach, but it gets exponentially uglier the further you sit from separation. With separate databases it's built in.
  • True resource isolation: a genuinely huge, hyperactive tenant will hurt everyone else unless you wall it off. The good news is a tenant that big usually comes with the budget to absorb the overhead.

Key point: these rarely apply to every tenant. Separate only the ones that need it, keep the rest cheap. The overhead of separation scales linearly with how many tenants you separate, so separate as few as you can.

You don't have to pick just one

It's a spectrum, not a menu with one choice. Combine.

By tenant: everyone runs on the discriminator column, except a couple of noisy neighbours who get their own database on a shared instance, and one enterprise client with a legal requirement who gets a dedicated instance in the right region.

By data: keep almost everything on the discriminator column, but pull one sensitive dataset into a separate database. This dodges a subtle cost. Every approach lives on either the connection layer (separate instance, separate database, Postgres schemas, RLS) or the query layer (partitioning, prefixes, discriminator column). Combining within a layer is easy. Combining across layers gets complicated. Splitting by data sidesteps that, because a given model just belongs to a different connection, which every modern ORM handles.

One thing crossing a database boundary always costs you: referential integrity. A foreign key can't point across databases, so once tenant data and a shared users table live apart, you enforce that link in application code, not with a constraint.

And sharding sits on a different axis entirely. It's about scale, not isolation, and it inherits whatever isolation level the approach you're sharding already has. It's horizontal where everything above is vertical, which is why it's its own conversation.

The cost you're actually choosing

Every approach has two costs. The architectural cost you pay once, when you build it. The operational cost you pay per tenant, every day, for the life of the app. That second one is the one that matters, and it's the one people underweight.

Run a migration once and it breaks? Roll back the migration, roll back the app, move on. Run it N times and it breaks on one tenant? Now you're working out which tenants took it, which didn't, and how to reconcile the gap. A migration pipeline that survives that is non-trivial and fragile even when it's well built. Same logic for backups: waiting on them isn't the problem, the pipeline and the management of N backup sets is. And if you back up your carefully separated tenants to one shared location, congratulations, you undid the separation you paid for.

The question isn't "how isolated do I need to be?" It's "what am I willing to pay, per tenant, forever, for isolation?" Ask it that way and the answer changes. If you don't have one of the hard requirements above, Postgres with a discriminator column and row-level security gives you the isolation without the standing bill. A separate database per tenant, with none of those requirements, gives you the bill and none of the benefit.

If there's one line to keep: the best isolation approach varies app to app, but it's almost never a database per tenant.

What to do now

Start at the cheap end. Give every tenant-owned table a tenant_id, wire a global scope so nobody hand-writes the filter, and if you're on Postgres, add RLS so the database backs you up. That's a couple of hours of work and it covers the vast majority of apps you'll ever build.

Then leave the heavier approaches on the shelf until a real requirement pulls one down. Moving a tenant to a heavier approach later is additive and straightforward. Going the other way, untangling separated tenants back into a shared model, is the migration nobody wants to run. Start light, escalate on demand, and don't solve a problem you don't have yet.

If you're weighing this on a client build and want a second pair of eyes on the trade-offs, that's the kind of call I help teams make. Have a look at my services or get in touch.

FAQ

Frequently asked

Almost never. A separate database per tenant gives you strong isolation but you pay for it on every migration, backup, and connection, forever. For most apps a single database with a tenant_id column on each table, filtered by a global query scope, gives you all the isolation you need. On PostgreSQL you can harden that with row-level security so the database enforces the boundary, not just your code.

It's a column, usually called tenant_id, account_id, or organization_id, on every table that holds tenant data. Every query filters on it so a tenant only ever sees its own rows. It's the lightest and cheapest isolation approach, works on any database engine, and is what most SaaS apps actually need. Shopify used this approach and sharded on top of it.

On PostgreSQL, yes, for the large majority of cases. Row-level security moves the tenant filter out of your application code and into a database policy, so a forgotten WHERE clause can't leak data. You set the tenant on the connection with a session variable and Postgres enforces the rest. Watch two things: table owners bypass policies unless you force them, and transaction-mode connection poolers can leak the session variable if you don't use SET LOCAL inside a transaction.

When you have a real requirement, not a nice-to-have. Three cases justify it: a regulation that demands physical isolation or data in a specific jurisdiction, per-tenant backup and point-in-time recovery you genuinely need, or a noisy-neighbour tenant so large it degrades everyone else. Even then you usually only separate the few tenants that need it and keep the rest on the cheap approach.

Isolation is a security and correctness concern: tenant A must never see tenant B's data. Separation is a storage concern: tenant A's data physically lives somewhere different from tenant B's. You can get full isolation without any separation, which is the whole point. People reach for database-per-tenant because they confuse the two and assume separation is the only way to be safe.

Enjoyed this? Let's talk.

Start a conversation →