Many customers share one system, but each must only ever see their own rows.
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.
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...';
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.
| Dimension | Shared table | Schema / tenant | Database / tenant |
|---|---|---|---|
| Isolation | Weakest (one filter) | Medium | Strongest |
| Cost per tenant | Lowest | Low–medium | Highest |
| Noisy neighbour | Shared resources | Mostly shared | Fully separated |
| Migration / ops effort | One schema to migrate | N schemas | N databases |
| Scale (many tenants) | Thousands easily | Hundreds–low thousands | Tens–hundreds |
WHERE tenant_id is fragile because correctness rides on every query, every developer, forever. Prefer Postgres Row-Level Security so the engine enforces it even when the app forgets.ALTER TABLE into N of them. At thousands of tenants a schema change becomes a long, partial-failure-prone rollout you must orchestrate.tenant_id. In shared tables every index and unique constraint should lead with tenant_id; otherwise queries scan across tenants and uniqueness leaks between them.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.
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?