IAnswerable

Multi-Tenancy: Shared Schema or Separate Databases

22 Jul 2026

Start with a shared schema and a tenant_id column. Almost every B2B SaaS that starts with separate databases per customer regrets it by customer number 50, and almost none of them needed the isolation it bought them.

The three options

Shared schema, shared tables. Every tenant’s rows live in the same tables, distinguished by a tenant_id column on everything.

CREATE TABLE orders (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id),
  customer_name text,
  created_at timestamptz DEFAULT now()
);
CREATE INDEX ON orders (tenant_id);

One database, one schema, one set of migrations. Adding a column is one ALTER TABLE, not a script that loops over 200 tenant databases. This is the cheapest option to build and the cheapest to operate.

Schema-per-tenant. Same database, but each tenant gets its own Postgres schema (tenant_42.orders). You get logical separation without the operational overhead of separate database instances, but migrations now have to run once per schema, and connection pooling gets awkward — you’re either switching search_path per request or juggling a pool per tenant.

Database-per-tenant. Full isolation. Each tenant is a separate database, sometimes a separate instance. This is the most expensive to run and the most expensive to change — a schema migration is now a fleet operation, not a single ALTER TABLE. It’s also the easiest to reason about for compliance: one tenant’s data physically cannot leak into a query for another tenant, because there’s no query that spans databases.

The comparison

Shared schema Schema-per-tenant DB-per-tenant
Migration cost One run Per-schema loop Fleet operation
Isolation Row-level, enforced by app or RLS Logical Physical
Cost at scale Lowest Medium Highest
“Export this tenant’s data” A filtered query A schema dump A database backup
Noisy-neighbor risk Real (shared indexes, shared load) Real None

Enforce isolation in the database, not just the app

If you go shared-schema, don’t trust every query in your codebase to remember WHERE tenant_id = ?. One missed clause in one report query leaks another company’s data, and that’s the kind of bug that ends a customer relationship. Postgres row-level security enforces it at the database layer so a forgotten WHERE clause fails closed instead of leaking:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

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

Set app.tenant_id once per connection or transaction based on the authenticated user, and every query — including ones a future engineer writes without thinking about multi-tenancy — is automatically scoped.

When to move off shared schema

Two real triggers, not “it feels safer”:

  1. A customer’s compliance requirement demands physical data isolation (common in healthcare, government, some finance contracts) — no amount of RLS convinces their auditor.
  2. One tenant’s load is degrading query performance for everyone else, and you can’t fix it with better indexes or read replicas.

Both are things you’ll know for certain when they happen, not things you should design around from day one. Building database-per-tenant at launch means every feature ships slower for the next two years to protect against a scaling problem you may never have. Start shared, add RLS, and split off the one noisy or compliance-bound tenant into its own database later — you don’t have to make the same choice for every customer.