Multi-tenant database

Many customers share one system, but each must only ever see their own rows.

The idea

Picture an apartment building. Everyone lives under one roof and shares the plumbing and the front door, yet each tenant has their own locked unit. A multi-tenant database works the same way: one running system serves many customers (tenants), but every tenant must only ever touch their own data.

There's a spectrum of how strongly you draw those walls. At one end, every tenant's rows live interleaved in the same tables and a tenant_id filter keeps them apart. At the other end, each tenant gets a whole separate database. The cheaper and more shared the design, the larger the central risk grows: a single forgotten filter can leak one customer's rows to another.

Pick a model and tenant, then press Play to watch the query get scoped.

How it works

In the shared-table model, every query carries the tenant's id and the database scopes the result to it. The fragile part is that the application must remember to add WHERE tenant_id = $1 on every query — miss it once and rows leak across tenants.

-- App-level scoping: correct, but easy to forget on one query
SELECT id, amount, note
FROM   invoices
WHERE  tenant_id = $1        -- the tenant from the authenticated session
  AND  status = 'open';

The stronger move is to push isolation below the app, into the database itself, with Postgres Row-Level Security. Set the current tenant once per connection, and the engine appends the filter to every query automatically — even the ones a developer forgets.

-- Belt-and-suspenders: the database enforces the filter for you
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

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

-- once per request, right after checking out a connection:
SET app.tenant_id = '...the-authenticated-tenant...';

Cost

The three models trade isolation against cost and operational effort. There's no single right answer — most SaaS products start shared and peel heavy tenants out as they grow.

DimensionShared tableSchema / tenantDatabase / tenant
IsolationWeakest (one filter)MediumStrongest
Cost per tenantLowestLow–mediumHighest
Noisy neighbourShared resourcesMostly sharedFully separated
Migration / ops effortOne schema to migrateN schemasN databases
Scale (many tenants)Thousands easilyHundreds–low thousandsTens–hundreds

Watch out for

Worked example

You launch an invoicing SaaS. To move fast you put every customer in shared tables with a tenant_id column, wrap every query in WHERE tenant_id = $1, and back it with Row-Level Security so a forgotten filter still can't leak data. Onboarding a customer is a single INSERT, and one migration covers everyone — ideal for a few hundred small tenants.

A year in, one enterprise customer imports millions of invoices and runs giant exports. Their heavy queries become the noisy neighbour: everyone else's dashboards slow down. The fix isn't a rewrite — you migrate just that one tenant to its own database, give it dedicated CPU and connections, and route their traffic there by tenant id. Small tenants stay shared and cheap; the whale gets isolation. This hybrid shape — shared by default, dedicated for the few heavy ones — is where most mature SaaS products land.

Check yourself

You want guaranteed tenant isolation that your application code can't accidentally bypass on a single forgotten query. What's your fastest path?

One large customer's heavy queries keep slowing everyone else down on the shared database. What's the problem, and a reasonable fix?