Multi-tenancy · How it works

18 min read
Senior14 min read
Rapid overview

How it works

The spectrum in one picture

flowchart LR P[Pool - shared tables, tenant column] --> R[Pool plus row-level security] R --> B[Bridge - schema per tenant] B --> S[Silo - database per tenant] S --> F[Full-stack silo - deployment stamp per tenant]

Left is cheapest and weakest, right is strongest and most expensive. Two further models, tiered and tenant sharding, are not points on the line but ways of mixing it.

1. Pool (shared schema + tenant column)

In plain words: everyone's rows live in the same tables, and each row carries a tenant_id saying whose it is.

Q: What is the single biggest risk of the pool model and why?

A: A missing tenant filter. In the pool model the only thing separating one customer's rows from another's is a WHERE tenant_id = ? that every query, report, background job and ad-hoc script has to remember. One forgotten predicate — in a new endpoint, a raw SQL report, an admin export — returns another tenant's data, and nothing in the database objects because as far as it knows the query is legal. That is why the pool model is normally paired with a mechanism that makes the filter impossible to forget: a repository or ORM global filter that injects it, and ideally row-level security in the database as a second, independent wall.

2. Pool + database row-level security (RLS)

In plain words: still shared tables, but the database itself hides rows that do not belong to the current tenant, even if the query forgets to filter.

  • Where the wall is: in the database engine — a policy such as USING (tenant_id = current_setting('app.tenant_id')::uuid) is applied to every statement on the table.
  • Pros: defence in depth — the application filter and the database policy must both fail for a leak; works for ad-hoc SQL and reporting tools too; keeps pool economics.
  • Cons: the tenant context must be set on every connection (pooled connections must reset it, or a request inherits the previous tenant); superusers and table owners bypass RLS unless FORCE ROW LEVEL SECURITY is set; policies add planner work and can hide index use if written carelessly; still one database for noisy neighbours.
  • Who uses it: Postgres-backed SaaS generally; Nile builds its tenant-aware Postgres around it; Supabase exposes it as the primary access-control mechanism.
  • Sources: PostgreSQL docs — Row Security Policies · AWS — Multi-tenant data isolation with PostgreSQL RLS · SQL Server — Row-Level Security (multi-tenant example) · Nile — multi-tenant RLS
Q: How can row-level security leak data even though the policy is correct?

A: Through the tenant context rather than the policy. RLS policies usually read the tenant from a session variable, and connection pools reuse sessions. If a request sets app.tenant_id and the connection goes back to the pool without it being reset — or if it was set with session scope instead of transaction scope (SET LOCAL) — the next request on that connection runs as the previous tenant, and the policy faithfully shows it that tenant's rows. The other classic gap is privilege: the table owner and superusers bypass RLS by default, so if the application connects as the owner the policy never applies. The fixes are to set the context per transaction, connect as a non-owner role, and use FORCE ROW LEVEL SECURITY.

3. Bridge (schema per tenant)

In plain words: one database, but each tenant gets its own copy of the tables in its own schema (namespace).

  • Where the wall is: in the schema — the connection's search_path (or a schema prefix) points at one tenant's tables.
  • Pros: a forgotten WHERE cannot cross tenants; per-tenant backup, export and deletion are easy (dump or drop one schema); a tenant can be moved to its own database later.
  • Cons: migrations run N times and can half-fail, leaving tenants on different schema versions; catalogue size grows with tenants × tables (thousands of schemas strain Postgres metadata and tooling); cross-tenant reporting needs a union over every schema; a user or integration that spans two tenants needs two search_path switches and an application-side merge; connection pooling per schema is awkward.
  • Who uses it: common in Rails and Django SaaS (the apartment and django-tenants libraries); Influitive documented running its advocacy platform this way.
  • Sources: AWS SaaS Lens — bridge model · django-tenants documentation · PostgreSQL — Schemas · Apartment gem README · Crunchy Data — designing Postgres for multi-tenancy · AWS Prescriptive Guidance — Postgres SaaS decision matrix
Q: Why does schema-per-tenant stop scaling at thousands of tenants even when each tenant is small?

A: Because the cost is in the catalogue and the operations, not the data. Every schema copies every table and index, so a hundred tables across five thousand tenants is half a million relations in the system catalogue, which slows planning, pg_dump, monitoring queries and anything that lists objects. Migrations become a loop over five thousand schemas that takes hours and can fail part-way, leaving tenants on different versions that the application must tolerate. The model is attractive for tens or hundreds of tenants who value clean per-tenant export and deletion, and painful beyond that.

4. Silo (database per tenant + tenant catalogue)

In plain words: every tenant gets its own database, and a small shared "catalogue" maps each tenant to where its database lives.

  • Where the wall is: at the database boundary — a request for tenant A physically connects to A's database.
  • Pros: strong isolation, easy to explain to auditors; per-tenant backup, restore, encryption keys, region and sizing; no noisy neighbours at the data layer; a tenant can be deleted by dropping its database.
  • Cons: cost per tenant (each database has a floor price); fleet operations — migrations, monitoring and upgrades across hundreds of databases; cross-tenant queries and users who belong to several tenants need a separate layer; the catalogue becomes a critical dependency; every database carries its own connections and memory, so on a small server the cost grows with the tenant count, not the data.
  • Who uses it: enterprise and regulated SaaS; Azure SQL elastic pools and the "database per tenant" pattern are built for it. Atlassian's engineering blog describes Jira running one PostgreSQL database per tenant, millions of databases across a few thousand database instances.
  • Sources: Azure — database-per-tenant pattern · AWS SaaS Lens — silo model · Azure — tenancy models, horizontally partitioned deployments · Atlassian — migrating Jira's database platform
Q: What is the tenant catalogue in a database-per-tenant design and why is it critical?

A: It is the small shared store that maps a tenant id to its database location — server, database name, region, sometimes the schema version and status. Every request resolves the tenant (from the token or the host name), looks it up in the catalogue, and opens a connection to that database. It is critical because it is on every request's path and is the one piece that is not isolated: if it is down no tenant can be served, and if it is wrong a tenant is routed to someone else's data. So it is cached aggressively, kept highly available, and changed only through a controlled onboarding and migration process.

5. Full-stack silo (Deployment Stamps)

In plain words: each tenant, or small group of tenants, gets its own complete copy of the application — compute, database, queues — called a stamp or cell.

  • Where the wall is: at the infrastructure boundary; tenants share nothing but the control plane and the routing layer.
  • Pros: maximum isolation, including performance and blast radius (a bad deploy or overload hits one stamp); data residency by placing stamps in regions; scales out by adding stamps rather than growing one giant system.
  • Cons: highest cost; requires mature automation (infrastructure as code, fleet deploys, per-stamp monitoring); rollouts across stamps take time and can drift; cross-stamp features are hard, and an integration that serves several tenants needs a separate login per stamp.
  • Who uses it: ServiceNow's multi-instance model gives each customer dedicated application nodes and a dedicated database; Atlassian Cloud and many hyperscale services run cell-based architectures; the Deployment Stamps pattern is documented by Microsoft, cells by AWS.
  • Sources: Azure Architecture Center — Deployment Stamps pattern · AWS — Reducing the scope of impact with cell-based architecture · Azure — tenancy models, automated single-tenant deployments
Q: What does a deployment stamp buy you that a database per tenant does not?

A: Isolation above the data layer. With a database per tenant, tenants still share application servers, queues, caches and the deploy pipeline, so a runaway tenant can exhaust shared compute and a bad release breaks everyone at once. A stamp gives a tenant or group its own full copy of the stack, so load, failures and releases are contained to one stamp, and a stamp can be placed in a specific region for data residency. The price is running many copies of everything, which only works with strong automation.

6. Tiered pool + silo

In plain words: most tenants share a pool; the few who pay for (or legally need) isolation get their own silo, running the same code.

7. Tenant sharding (distributed database keyed by tenant)

In plain words: keep the pool model's shared tables, but spread tenants across many database nodes, keeping each tenant's rows together on one node.

  • Where the wall is: still the tenant column (plus RLS if added); the sharding is about scale, not isolation.
  • Pros: the pool model grows past one machine; queries scoped to one tenant stay on one node and stay fast; large tenants can be moved to their own node.
  • Cons: cross-tenant queries fan out to every node; choosing tenant_id as the distribution key must be done early (every table needs it, including in primary keys); rebalancing is an operational event.
  • When it is premature: it solves a data-size problem. Until one server (plus read replicas) cannot hold the pool, it only adds nodes to run.
  • Who uses it: Citus (Postgres) was built around this for multi-tenant SaaS, and Freshworks runs Citus for billions of rows; Vitess shards MySQL similarly.
  • Sources: Citus docs — multi-tenant applications · Vitess — sharding · Azure SQL — sharded multitenant databases
Q: What is the difference between tenant sharding and database-per-tenant?

A: Sharding is about capacity, silo is about isolation. Tenant sharding keeps one logical database with shared tables and a tenant column, and distributes tenants across nodes so each tenant's rows are co-located — the application still relies on the tenant filter (or RLS) for separation, and many tenants share each node. Database-per-tenant gives every tenant a physically separate database, so separation holds even when a query forgets to filter. You can combine them, and systems often do: shard the pool, and silo the tenants who need it.

Comparing the models

Figures are orders of magnitude from vendor guidance, not benchmarks.

ModelWall enforced byCost per requestNoisy neighbourComfortable rangeGrows byMigrations per changeUser spanning two tenants
Poolapplication / ORM filter~0 with an indexed tenant columnsharedtens to 100k+ tenantsbigger server, read replicas, then sharding1easy
Pool + RLSthe databasesmall policy check per statementsharedas poolas pool1needs a membership-aware policy
Bridgeschema~0shared serverup to about a thousand schemascatalogue bloat is the ceilingNunion over schemas
Silodatabase boundarya connection pool per databasenone at the data layerup to about a thousand databases per pooled serveradd database serversNcross-database query
Stampsinfrastructure~0noneone to tens of stampsadd stampsNeffectively impossible
Tieredper tieras pool or silopooled tier onlymany small + few largemove a tenant to a silo1 + kmixed
Tenant shardingtenant column, routed~0 when routed; cross-shard slowerper shard10k+ tenants, high volumeadd worker nodes1cross-shard query

Choosing a model

Answer these in order; each one eliminates models.

  1. How many tenants, and how big is the biggest? Thousands of small tenants rule out schema-per-tenant and full-stack silos on cost and operations.
  2. What isolation do customers or regulators demand? A contract that says "our data in a separate database" or a residency law settles it regardless of cost.
  3. Can one user belong to several tenants, or must you query across tenants? Silos make both expensive; pool makes them easy.
  4. What is the blast radius you can accept? If one tenant's load or one bad deploy must not affect others, you are heading toward stamps.
  5. Can you automate a fleet? Silo and stamps are only as good as your migration and deploy automation.
If you have...Prefer
up to hundreds of tenants on one database serverpool with an ORM filter, then RLS as the safety net
a few large or regulated tenants who need their own datatiered: pool plus a silo for those few
tens of thousands of tenants or data past one servertenant sharding, same code as the pool
a contract that demands separate hostinga stamp for that customer only

Refuse on the constraint first. If one user or integration must read two tenants in a single call, bridge, silo and stamps are out before cost is even compared. If you plan to consolidate services onto one database, the physically separated models fight that plan too.

Q: A B2B product has 2,000 small tenants and three enterprise customers asking for dedicated databases. What do you propose?

A: A tiered model: keep the 2,000 small tenants in a shared pool — shared tables, a tenant column, an ORM-level filter, and Postgres row-level security as a second wall — and give the three enterprise customers their own databases running the same code, routed through a tenant catalogue that records each tenant's tier and location. That keeps the long tail cheap and turns isolation into something the enterprise plan pays for. The rules that make it work are that no code may assume the pool (every query is tenant-scoped either way), that migrations run through one pipeline for pool and silos, and that moving a tenant between tiers is a planned export-and-import, not an afterthought.

Q: Why is "defence in depth" the standard advice for pooled tenancy?

A: Because a single application-level filter is one bug away from a data breach, and cross-tenant leaks are among the most damaging failures a SaaS company can have. Adding an independent second mechanism — database row-level security that applies even when the query forgets — means two unrelated things must fail at once for a leak. The two walls also fail differently: the application filter is lost in a new endpoint, the RLS context is lost in connection handling, so testing each catches a different class of bug.

Q: How does the tenant get resolved on each request?

A: From something the caller cannot forge. The usual sources are a claim in a signed access token (a tenant_id claim issued by the identity provider), the host name (acme.example.com) mapped through the tenant catalogue, or an explicit selector such as a header that is checked server-side against the user's memberships — never a tenant id taken on trust from a request body or query string the user controls. Once resolved it is put into a request-scoped context that the data layer reads to set the ORM filter or the RLS session variable, so individual handlers never pass the tenant id by hand.

Resolving the active tenant

Once a user can belong to more than one tenant, every request must say which tenant it acts for, and the data layer needs that answer before its first query.

OptionHowStrengthWeakness
Headere.g. X-Tenant-Id, checked against cached membershipsno route changes; an integration can switch tenant per callclient must send it; a missing header is a 400
Token claimthe tenant id is inside the signed tokennothing extra per callswitching tenant means a new token; multi-tenant clients juggle tokens
Path/tenants/{id}/projects/3explicit, visible in logsevery route changes
Resource-derivedlook up the owner of the requested resourceclient sends nothinglist endpoints have no single resource to derive from
Hostacme.example.comstandard for SaaS portalsone call cannot span two tenants

A header or path is only a selector: the server accepts it only if the authenticated user holds a membership in that tenant. Removing a membership should cut access as fast as removing any other permission (see token versioning in the Authorization models module).

Tenant wall plus grants: two layers

Tenancy and permissions answer different questions, and keeping them separate is what makes a forgotten check survivable.

LayerAnswersBuilt with
Tenantwhich tenant's rows exist for this request at alltenant column + ORM filter (+ RLS)
Membershipwhich tenants a user belongs toa user-to-tenant membership table
Grantwhich project or item inside the tenant, and view or edita grants table (user, role, scope)

With the tenant wall in the data layer, a handler that forgets its permission check can still over-share inside one tenant, but it cannot leak another tenant's rows. Without it, every handler is one missing if away from a cross-tenant breach.

Q: Why keep the tenant filter separate from per-item permission checks?

A: Because they fail differently and the tenant wall should not depend on every handler being correct. The tenant filter lives in the data layer and applies to every query automatically, so it decides which tenant's rows exist for the request at all. Permission checks decide which items inside that tenant a user may view or edit, and they live closer to the business logic where they are easier to forget. If a developer forgets a permission check, the damage stays inside one customer; if the tenant boundary were just another hand-written permission check, the same slip would expose another customer's data.

Q: A user belongs to three tenants. How should each request say which one it is acting for?

A: With an explicit selector the server validates, most often a header or a path segment, checked against the user's memberships before any query runs. Putting a single tenant in the token also works but forces a new token on every switch, which hurts integrations that act for several tenants in a row; deriving the tenant from the requested resource fails on list endpoints; a host name cannot span two tenants in one call. Whatever carries it, the resolved tenant goes into a request-scoped context the ORM filter and RLS read, and removing a membership must cut access as quickly as removing any other permission.

See also