Every SaaS faces the multi-tenancy decision. There are three real patterns; each has a clear sweet spot. This post is the decision guide.

The three patterns

1. Shared schema (tenant_id column)

Every table has a tenant_id; queries filter by it.

SELECT * FROM orders WHERE tenant_id = $1 AND status = 'paid';
  • Pros: Cheapest. Easy migrations. Works to ~10k tenants comfortably.
  • Cons: Bug forgetting WHERE → cross-tenant leak. Noisy neighbors share resources.
  • Best for: Startup phase, B2B SaaS with cooperative tenants.

2. Pooled (schema-per-tenant)

Each tenant gets a Postgres schema (tenant_42.orders); the app connects to the right schema by tenant.

SET search_path TO tenant_42;
SELECT * FROM orders;
  • Pros: Stronger isolation. Per-tenant migrations possible. Easier per-tenant export.
  • Cons: Schema migrations across 1000s of schemas is operational effort.
  • Best for: B2B with mid-sized tenants and stronger isolation needs.

3. Per-tenant database

Each tenant gets its own database.

  • Pros: Maximum isolation. Easy per-tenant backups, scaling, compliance. Cross-tenant data leaks are physically impossible.
  • Cons: Most expensive (DB per tenant). Schema migrations across many DBs is real ops.
  • Best for: Enterprise SaaS with strict isolation, regulated industries, large tenants.

Decision matrix

SharedPooledPer-tenant
Up to 100 tenants⚠️
100–10k tenants⚠️
10k+ tenants⚠️⚠️
Compliance / strong isolation⚠️
Per-tenant export ease⚠️
Migration costLowMediumHigh
Noisy-neighbor riskHighMediumNone

Postgres RLS as defense in depth

For shared-schema setups, Row-Level Security catches the “I forgot the WHERE clause” bug:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::int);

Then in the app, set the session variable on connection check-out:

async def get_db_with_tenant(tenant_id: int):
    conn = await pool.acquire()
    await conn.execute("SET app.tenant_id = $1", tenant_id)
    return conn

Now every query is automatically scoped. A bug that omits WHERE returns 0 rows, not other tenants’ data.

For Postgres specifics see PostgreSQL MVCC, Isolation, Locking .

Migration story

Shared schema → pooled → per-tenant is the typical evolution. Plan:

  1. Start shared with strict tenant_id discipline + RLS.
  2. Move to pooled when tenant count + isolation needs demand it.
  3. Move large enterprise tenants to dedicated DBs as one-offs.
  4. Per-tenant for everyone only if compliance forces it.

Many SaaS companies stay shared forever. Don’t migrate prematurely.

Per-tenant resource limits

Even on shared, throttle per tenant:

  • API rate limit per tenant (see Design a Rate Limiter ).
  • Background-job concurrency cap per tenant.
  • Storage quotas.
  • Compute budgets.

Without these, one runaway tenant impacts everyone.

Per-tenant features

Pair multi-tenancy with feature flags for tenant-scoped rollouts. Enterprise tenants often want different defaults.

Common mistakes

1. Forgetting WHERE clauses

Without RLS, one missed WHERE tenant_id = $1 is a breach. Always RLS.

2. Shared cache without tenant in key

Cache key user-42 collides with tenant A and tenant B’s user 42. Always tenant-scope: tenant-X:user-42.

3. Cross-tenant queries by mistake

SELECT * FROM orders without tenant filter returns everything. Defaults matter.

4. Backups not tenant-scoped

For per-tenant restore (a big tenant’s accidental delete), per-tenant backups matter.

5. Onboarding friction

If creating a new pooled-tenant or per-tenant DB takes 30 minutes of ops, signup conversion suffers. Automate onboarding.

Read this next

If you want a Postgres + RLS multi-tenant starter, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .