Filter concepts by levelShowing all levels.

System Design · Section 60

Multi-Tenancy

Level
intermediate
Read
18 min
Concepts
3

A multi-tenant system chooses, per resource, how much of the database tenants share: shared database/shared schema (one set of tables, a tenant_id column), shared database/separate schema (one instance, one schema per tenant), or separate database per tenant (nothing shared at all) — isolation strength and operational cost move in opposite directions across the three. Whichever model is chosen, tenant isolation has to be enforced independently at every access path a request touches — API, database query, cache keys, file paths, queue messages, and background jobs — since a correct tenant_id filter on the main query says nothing about whether a cache key or a queue message also carries tenant scoping, and a cache key built from a user id alone is the most common silent way to leak one tenant's data to another. The tenancy model chosen also changes where noisy-neighbor risk lives: shared schema makes a per-tenant quota on the shared connection pool close to mandatory, while separate database removes that risk at the database layer but not at whatever shared gateway or load balancer still sits in front of every tenant's isolated database.

System Design overview

What is true here

  1. Shared schema, separate schema, and separate database trade isolation strength against operational cost — strongest isolation is also the most expensive to run per tenant.
  2. Tenant isolation must be enforced independently at every access path — API, query, cache keys, files, queues, background jobs — not assumed from a correct database query alone.
  3. A cache key missing the tenant id is the most common silent leak: two tenants whose user ids collide can be served each other's cached data with no error anywhere.
  4. Noisy-neighbor exposure changes with the tenancy model: shared schema makes a per-tenant quota close to mandatory; separate database removes database-layer risk but not risk at any shared layer still in front of it.

What you will be able to do

  • Choose between shared schema, separate schema, and separate database based on a tenant's actual isolation and compliance requirements
  • Audit a request's full path — API, query, cache, files, queues, jobs — for a tenant scoping gap, not just the main database query
  • Recognize a cache-key collision as a tenant isolation failure distinct from a database query mistake
  • Identify which layer a noisy-neighbor incident most likely originates in, given the tenancy model already in place

Choosing a tenancy model

The three ways to partition tenant data, and the isolation-vs-cost trade-off between them.

Tenancy models: shared schema, separate schema, separate database

coreintermediate

A multi-tenant system has to decide, per resource, how much of the database each tenant shares with every other tenant. Shared database/shared schema puts every tenant's rows in the same tables, distinguished only by a tenant_id column. Shared database/separate schema still uses one database instance but gives each tenant its own schema (its own set of tables inside that instance). Separate database gives each tenant an entirely distinct database, sometimes on its own host. Each step trades lower operational cost for weaker isolation, so the right choice depends on what a single tenant's data leak or one tenant's heavy load is allowed to cost the others.

Think of it as

Think of three ways to run a shared office building. Shared schema is an open-plan floor: everyone's desks are in the same room, separated only by a nameplate — cheap to build, but a wrong turn puts you at someone else's desk. Separate schema is individual offices on the same floor sharing one reception desk and one electrical system: real walls between tenants, but a building-wide power outage or a receptionist's mistake still affects everyone. Separate database is each tenant getting its own building: nothing physically shared at all, at the cost of running many buildings instead of one.

text
Shared schema:   one DB, one schema, tenant_id column on every table
Separate schema: one DB, N schemas, one set of tables per schema
Separate DB:     N databases, one set of tables per database

What we're doing: Compare how the same "list this tenant's open tickets" query is scoped under each of the three models.

tenancy-models-query.sqlsql
-- 1. Shared database, shared schema
--    Isolation lives entirely in application code.
SELECT id, subject FROM support_tickets
WHERE tenant_id = 'acme-corp' AND status = 'open';

-- 2. Shared database, separate schema
--    Isolation lives in the schema boundary; still one
--    engine, so a runaway query can still starve others.
SET search_path TO acme_corp;
SELECT id, subject FROM support_tickets
WHERE status = 'open';

-- 3. Separate database per tenant
--    Isolation lives in the connection itself: this
--    connection can only ever see acme_corp's data.
\c acme_corp_db
SELECT id, subject FROM support_tickets
WHERE status = 'open';
3
The WHERE tenant_id filter is the entire isolation boundary here — omit it once, in one query, and every tenant's tickets return together.
8
The schema switch scopes the query without a tenant_id column, but the connection pool, CPU, and disk I/O underneath are still shared with every other tenant's schema.
15
By the time the query runs, tenant scoping already happened at connection time — there is no shared table left for a missing filter to expose.

Why this works: The same logical query needs a different amount of application trust at each step: shared schema trusts every query author to remember the filter, separate schema trusts the connection-time schema switch, and separate database needs no per-query trust at all because there is nothing shared left to mis-scope.

Choosing shared schema for a tenant with a hard compliance requirement for data separation

Wrong

text
# Regulated healthcare tenant onboarded onto the
# same shared-schema tables as every other tenant,
# because it was the fastest model to ship with

Better

text
# Regulated tenant placed in its own database from
# day one -- onboarding is slower, but "which
# tenants' data lives in this database" has a
# one-word answer during an audit

What you see: A compliance audit or customer security questionnaire asks to prove that tenant data is physically separated, and the honest answer is "it is not — isolation depends on every one of several thousand queries carrying a correct tenant_id filter," which is not an answer regulators or enterprise security teams accept.

Why: Isolation strength is a property of the architecture, not of how careful the current engineering team is — a compliance requirement for data separation needs a model where a code review mistake cannot cross a tenant boundary, which shared schema structurally cannot guarantee no matter how disciplined the team is.

Shared schema vs. separate database — what actually separates two tenants

Shared schema (weak isolation, low cost)

  • +One tenants table, one tickets table, one everything table
  • +Only a WHERE tenant_id = ? clause keeps rows apart
  • +One connection pool and one migration for every tenant at once

Separate database (strong isolation, high cost)

  • Each tenant's tables live in a database only that tenant's connection can reach
  • A missing WHERE clause cannot leak another tenant's rows — there is nothing to leak into
  • N databases to provision, back up, migrate, and monitor instead of one
  • Shared schema (weak isolation, low cost)
    • One tenants table, one tickets table, one everything table
    • Only a WHERE tenant_id = ? clause keeps rows apart
    • One connection pool and one migration for every tenant at once
  • Separate database (strong isolation, high cost)
    • Each tenant's tables live in a database only that tenant's connection can reach
    • A missing WHERE clause cannot leak another tenant's rows — there is nothing to leak into
    • N databases to provision, back up, migrate, and monitor instead of one

Three tenancy models compared

Three tenancy models compared
ModelIsolation strengthOperational costCost per tenant at scale
Shared database, shared schemaWeakest — a missing tenant_id filter on one query leaks all tenants' rowsLowest — one schema, one migration, one connection pool to operateLowest — tenants share fixed overhead, marginal cost per tenant is small
Shared database, separate schemaModerate — table-level isolation, but host and engine resources still sharedModerate — one migration run per schema, still one instance to patch and monitorModerate — schema count grows linearly with tenants, but instance count does not
Separate database per tenantStrongest — a compromised or overloaded tenant cannot reach another tenant's data or resourcesHighest — N instances (or N logical databases) to provision, migrate, back up, and monitorHighest — reserved capacity per tenant, migrations and upgrades run N times over

Together

sql
-- Shared schema: every query carries the tenant filter
SELECT id, subject, status
FROM support_tickets
WHERE tenant_id = 'acme-corp'
  AND status = 'open';

-- Separate schema: the schema name itself scopes the query
SELECT id, subject, status
FROM acme_corp.support_tickets
WHERE status = 'open';

-- Separate database: the connection itself is already scoped
-- (acme_corp's own database, its own connection string)
SELECT id, subject, status
FROM support_tickets
WHERE status = 'open';

Remember: Shared schema is cheapest but leans entirely on application code getting every tenant_id filter right; separate database is the only model where a mistake structurally cannot cross tenant boundaries. Isolation strength and operational cost move in opposite directions, and the choice can be made per tenant, not just once for the whole product.

See also: isolation at every access path · noisy neighbor and quotas in multi tenant systems

Advertisement

Isolation at every access path, and shared-resource risk

Why a correct database query is not enough on its own, and how the chosen tenancy model changes where noisy-neighbor risk actually lives.

Tenant isolation must be enforced at every access path

coreintermediate

A tenant_id filter on the main database query is not tenant isolation — it is one guard on one path. Every other place tenant data passes through needs the same scoping applied independently: the API layer must authorize the request against the right tenant, cache keys must include the tenant id (or a tenant-scoped cache lookup silently returns another tenant's cached data), file storage paths must be namespaced per tenant, and queue messages and background jobs must carry and re-check a tenant id rather than assuming whatever produced the message already validated it. Missing any one path leaks data between tenants even when every other path is correct.

Think of it as

Think of tenant isolation like keeping two companies' mail apart when they share one office building, one mailroom, one filing room, and one courier service. Locking the mailroom door (the API layer) does not stop a misfiled letter in the shared filing cabinet (the database query), a courier who grabs "the package on the desk" without checking whose desk it was (the cache), a shared storage closet with no labels (files), or a delivery dispatcher who forwards a package based on a route number instead of checking the addressee (a queue message or background job). Every one of those five points needs its own check — securing four of them and missing the fifth still gets company A's mail delivered to company B.

text
Access paths a multi-tenant request can touch, and what
scopes each one:

  API request      -> tenant resolved from auth token/subdomain
  Database query    -> WHERE tenant_id = ? (or schema/DB boundary)
  Cache key         -> tenant id embedded in the key itself
  File/object path  -> tenant id embedded in the storage path
  Queue message      -> tenant id in the message payload/headers
  Background job    -> tenant id passed explicitly, re-checked
                        on every read the job performs

What we're doing: Show a cache key that omits the tenant id leaking one tenant's dashboard data to another tenant.

cache-key-collision.txttext
10:00  Tenant A (user id 42) requests their billing
       dashboard. The API correctly authorizes the
       request against tenant A.
10:00  The dashboard service checks the cache using
       key "dashboard:42" (keyed by user id only,
       no tenant id) -- cache miss, queries the DB
       with a correct "WHERE tenant_id = 'a'" filter,
       and writes the result to "dashboard:42".
10:03  Tenant B (unrelated company, whose own user
       happens to also have id 42) requests their
       billing dashboard.
10:03  The dashboard service checks the cache using
       the same key, "dashboard:42" -- cache HIT.
       Tenant B is served tenant A's cached billing
       dashboard: invoice totals, payment method
       details, everyone's name at tenant A.
10:03  No error is logged anywhere. The database
       query never even runs for tenant B's request
       -- the cache served the wrong answer with
       full confidence.
6
The database query itself is scoped correctly with a tenant_id filter -- this path was never the problem.
8
The cache key uses only the user id, not the tenant id -- this is the single missing scope that breaks isolation.
14
A cache hit skips the database entirely, so the one correctly-scoped path in this whole request never gets a chance to catch the mistake.

Why this works: The database query -- the path most engineers think of first when they hear "tenant isolation" -- was correct the entire time; the leak came from a second, independently-scoped path (the cache) that nobody re-verified, which is exactly why isolation has to be checked per access path rather than assumed once a query looks right.

Caching a database result under a key that omits the tenant id

Wrong

text
def get_dashboard(user_id):
    key = f"dashboard:{user_id}"
    cached = cache.get(key)
    if cached:
        return cached
    result = db.query(
        "SELECT * FROM billing WHERE user_id = %s",
        [user_id],
    )
    cache.set(key, result, ttl=300)
    return result

Better

text
def get_dashboard(tenant_id, user_id):
    key = f"tenant:{tenant_id}:dashboard:{user_id}"
    cached = cache.get(key)
    if cached:
        return cached
    result = db.query(
        "SELECT * FROM billing "
        "WHERE tenant_id = %s AND user_id = %s",
        [tenant_id, user_id],
    )
    cache.set(key, result, ttl=300)
    return result

What you see: Two different tenants whose internal user ids happen to collide (a near-certainty at scale, since user ids are usually assigned per-tenant starting from 1, or reused across a small numeric range) intermittently see each other's cached data — support tickets describe it as "sometimes I briefly see someone else's name/invoice," which is hard to reproduce because it depends on cache timing and which tenant's request happened to populate the shared key first.

Why: A cache key is its own independent access path with its own scoping requirement — fixing the database query's tenant_id filter does nothing for a cache key built from user_id alone, because the cache is checked before the query ever runs and returns whatever the last writer of that exact key stored, regardless of which tenant is asking now.

One request, six hops -- every hop needs its own tenant scoping
authorizedrequestresultcachedattachmentfetchedprocessingenqueuedconsumed

API

tenant resolved from auth token

DB query

tenant_id in WHERE clause

Cache

tenant id embedded in key

File storage

tenant id embedded in path

Queue

tenant id in message payload

Background job

tenant id re-checked, not assumed

  • API — tenant resolved from auth token
    • leads to DB query (authorized request)
  • DB query — tenant_id in WHERE clause
    • leads to Cache (result cached)
  • Cache — tenant id embedded in key
    • leads to File storage (attachment fetched)
  • File storage — tenant id embedded in path
    • leads to Queue (processing enqueued)
  • Queue — tenant id in message payload
    • leads to Background job (consumed)
  • Background job — tenant id re-checked, not assumed

Remember: A tenant_id filter on the main query is one access path out of at least six: API, query, cache keys, files, queues, and background jobs. Cache keys are the most common silent miss — a key built from a user id alone collides across tenants and serves the wrong tenant's data with no error anywhere.

See also: tenancy models · noisy neighbor problem

Noisy-neighbor prevention and quotas across the three tenancy models

coreintermediate

Noisy-neighbor risk and the fixes for it (weighted fairness, per-tenant queues, resource isolation) are covered in full elsewhere — what matters here is that the tenancy model you picked already answers part of the question. Shared schema means every tenant shares the same connection pool, so noisy-neighbor risk is at its highest and per-tenant quotas are not optional. Separate schema still shares the database engine and host, so quotas remain necessary but the blast radius of one tenant is smaller. Separate database removes the shared-resource risk at the database layer entirely — the model itself is a form of resource isolation, though shared infrastructure elsewhere (a shared API gateway, a shared load balancer) can still need its own quotas.

Think of it as

Picture the three tenancy models as three ways to share a kitchen. Shared schema is one kitchen, one stove, one set of pots for every tenant's cooking — a per-tenant quota here is like a strict rule ("each household gets 20 minutes of stove time"), and it is the only thing standing between one household's all-day cooking and everyone else going hungry. Separate schema is separate pots and pantries in the same kitchen — smaller collisions, but still one stove and one sink to queue for. Separate database is everyone getting their own kitchen — noisy-neighbor at the stove is structurally impossible, though the building's shared electricity (a load balancer, an API gateway) can still need its own per-unit cap.

text
Noisy-neighbor exposure by tenancy model (database layer):

  Shared schema     [############################] highest
  Separate schema   [###############             ] moderate
  Separate database [                             ] lowest

  (Full mechanics -- fairness algorithms, hard quotas vs
   burst limits -- covered in Quotas and Fairness, not here.)

What we're doing: Diagnose the same "tenant B slows down while B's traffic is flat" symptom differently depending on which tenancy model is in place.

noisy-neighbor-by-model.txttext
Incident: Tenant B reports slow dashboard loads.
B's own request volume graph is flat -- unchanged
from yesterday.

Case 1: Product runs shared schema, all tenants in
one set of tables, one connection pool.
  -> Check tenant A's query volume first: a shared
     schema means A's heavy batch job can consume
     the pool B is also drawing from. Root cause:
     no per-tenant cap on the shared pool.

Case 2: Product runs separate database per tenant.
  -> Tenant A's load cannot reach B's database at
     all -- rule out the database layer immediately
     and check the shared API gateway or load
     balancer's per-tenant rate limit instead.
     Root cause is one layer up from where it would
     have been under shared schema.
8
Under shared schema, tenant A is the first and most likely suspect because the connection pool genuinely is shared infrastructure between A and B.
15
Under separate database, the same investigation would waste time looking at the database — the tenancy model already ruled that layer out, so the search has to move to whatever is still shared (the gateway, the load balancer).

Why this works: The tenancy model chosen earlier in this section directly determines which layer to investigate first during a noisy-neighbor incident — the same symptom has a different most-likely root cause depending on how much was already isolated by the database architecture, before any fairness mechanism even gets involved.

Applying the same per-tenant quota strategy regardless of the tenancy model already in place

Wrong

text
# "We migrated from shared schema to separate
# database per enterprise tenant, but kept the
# exact same per-tenant connection-pool quota
# code running against each tenant's own,
# already-isolated database"

Better

text
# After migrating to separate database per tenant:
# drop the now-redundant per-tenant DB connection
# quota (each tenant already has its own pool by
# construction) and instead add a quota at the
# layer that is still shared -- the API gateway
# or load balancer in front of all tenant DBs

What you see: Engineering time gets spent tuning and monitoring a per-tenant database connection quota that can no longer actually fire the way it used to — each tenant's own database already caps what that tenant can consume — while the genuinely shared layer above it (the API gateway) has no quota at all and is where the next noisy-neighbor incident actually originates.

Why: A per-tenant quota is only load-bearing at a layer that is genuinely shared across tenants — once separate database removes database-layer sharing, a quota still aimed at that layer is solving a problem the architecture already solved, while the actual remaining shared layer goes unguarded.

Where a per-tenant quota is load-bearing, by tenancy model

Shared schema — quota is the only defense

  • +One connection pool serves every tenant's queries
  • +No structural barrier stops tenant A from consuming most of it
  • +A per-tenant cap on the shared pool is effectively mandatory

Separate database — isolation is structural

  • Each tenant has its own connection pool by construction
  • Tenant A exhausting its own pool cannot touch tenant B's
  • Remaining risk moves up the stack to shared gateway/LB layers
  • Shared schema — quota is the only defense
    • One connection pool serves every tenant's queries
    • No structural barrier stops tenant A from consuming most of it
    • A per-tenant cap on the shared pool is effectively mandatory
  • Separate database — isolation is structural
    • Each tenant has its own connection pool by construction
    • Tenant A exhausting its own pool cannot touch tenant B's
    • Remaining risk moves up the stack to shared gateway/LB layers

How each tenancy model changes noisy-neighbor exposure at the database layer

How each tenancy model changes noisy-neighbor exposure at the database layer
Tenancy modelShared resource at riskNoisy-neighbor exposurePer-tenant quota needed?
Shared schemaConnection pool, tables, query planner, locksHighest — one heavy tenant's query directly contends with every other tenant'sYes — close to mandatory from day one
Separate schemaDatabase engine, host CPU/memory/I/OModerate — table and lock contention removed, resource contention remainsYes — still needed, smaller blast radius per incident
Separate databaseNothing at the database layer; shared infra above it (gateway, load balancer)Lowest at the database layer — isolation is structural, not enforced by policyOnly above the database layer (gateway/API-level quotas), not for the DB itself

Together

text
# Same symptom -- tenant B's requests slow down while
# B's own traffic is flat -- means something different
# depending on the tenancy model already in place:

Shared schema:    likely cause -> tenant A monopolizing
                  the one shared connection pool
Separate schema:  likely cause -> tenant A saturating
                  shared host CPU/IO, not table locks
Separate database: likely cause -> NOT the database --
                  check the shared API gateway or LB
                  quota next, since the DB is isolated

Remember: Shared schema makes per-tenant quotas close to mandatory (one shared connection pool); separate database removes noisy-neighbor risk at the database layer but not at whatever shared infrastructure still sits in front of it (gateway, load balancer). The failure signature, hard-quota-vs-burst-limit distinction, and the three fairness mechanisms themselves are covered in Quotas and Fairness — this is about which tenancy model changes where those mechanisms are actually needed.

See also: tenancy models · isolation at every access path · noisy neighbor problem · quotas vs burst limits · fairness mechanisms

Advertisement