Module P-8 — Multi-Tenancy: Silo, Pool, Bridge, and Noisy Neighbours
What this module covers: The three isolation models — silo (database or cluster per tenant), pool (shared tables with a tenant_id column), and bridge (schema per tenant) — compared on migrations, backup granularity, blast radius, per-tenant cost, and onboarding speed. Then the specific way each one hurts: silo's migration queue and cost floor, bridge's catalogue and tooling collapse, pool's missing WHERE clause, and row-level security as the structural fix for the last of those. Then the operational core of the topic — noisy neighbours, and the four levers that contain them. Finally, how to promote one large tenant out of the pool and into a silo without downtime.
Every Serious System Eventually Serves Someone Else's Customers
Up to this point the course has treated your data as your data. One logical dataset, partitioned for size (Module P-6) and placed for locality (Module P-7). Multi-tenancy breaks that assumption in a specific way: the dataset now has a hard internal boundary that most of your queries must respect, and the traffic arriving at it is no longer one aggregate load curve but hundreds or thousands of independent ones, each with its own peak, its own growth rate, and its own capacity to ruin everyone else's afternoon.
This module treats multi-tenancy as a partitioning and capacity problem. Where does the tenant boundary live in the storage layout, and how do you stop one tenant consuming a shared resource that everyone depends on. The enforcement question — proving that a request genuinely belongs to the tenant it claims, and the confused-deputy risk when an internal service holds broader privileges than the caller who invoked it — is Module A-13's subject, and it is a different problem with different tools. A perfect isolation model with a broken authorisation check leaks data anyway.
This is the first step of a through-line that runs the length of the course: P-8 chooses the isolation model → Module P-18 puts per-tenant limits on it → Module A-13 enforces the boundary and closes the confused-deputy hole → Module O-8 turns it into a cost-per-tenant number your finance team can read. The choice you make here determines how expensive each of the later three is.
Silo, Pool, and Bridge Are Three Places to Put the Same Boundary
All three models answer one question — at what level of the storage hierarchy do tenants stop sharing?
Silo. Each tenant gets its own database, or its own cluster. Nothing is shared below the application tier. The tenant boundary is an infrastructure boundary.
Pool. All tenants share the same tables. Every tenant-scoped row carries a tenant_id column, and every query filters on it. The tenant boundary is a predicate.
Bridge. All tenants share one database instance, but each gets its own schema (in Postgres terms, its own namespace with its own copy of every table). The tenant boundary is a namespace.
Analogy: silo is a detached house per tenant — own plumbing, own meter, own front door, and someone has to maintain every one of them. Pool is an open-plan floor with assigned desks: cheap, dense, and the only thing stopping you reading your neighbour's screen is that you're supposed to look at your own. Bridge is a block of flats — separate front doors, shared water main, shared lift, and a building manager whose job gets harder with every flat added.
Compared on the properties that actually decide the argument:
Property
Silo (DB/cluster per tenant)
Bridge (schema per tenant)
Pool (shared tables + tenant_id)
Schema migrations
N migrations, N failure modes, hours-to-days for large N (Module O-4)
N migrations inside one transaction-per-schema loop; catalogue locks bite
One migration, one plan, one rollback
Backup/restore granularity
Native — restore one tenant to a point in time with no custom tooling
Per-schema dump possible, but a full restore is all-or-nothing
None by default; per-tenant restore is a custom export/import job
Blast radius of a bad deploy or bad query
One tenant
All tenants on that instance
All tenants
Noisy-neighbour exposure
Low (per-tenant resources)
High (shared buffers, WAL, connections, autovacuum)
High (shared everything, plus shared indexes)
Per-tenant cost floor
High — a mostly-idle instance still costs its baseline
Low
Lowest; marginal cost per small tenant approaches zero
Onboarding a new tenant
Provision infrastructure: seconds-to-minutes at best, and it can fail
CREATE SCHEMA + full DDL: fast but not instant, and it's a write to the catalogue
INSERT one row into tenants
Per-tenant customisation (extra columns, indexes)
Easy, and a trap
Easy, and a bigger trap
Requires a general mechanism (JSONB, Module F-13)
Cross-tenant analytics
Requires shipping data out (Modules P-22, P-23)
Awkward: UNION ALL across N schemas
Trivial: GROUP BY tenant_id
"Which tenant is expensive?"
Read the bill (Module O-8)
Hard
Hard without per-tenant instrumentation
Compliance/data residency
Straightforward — put the silo in the region
Only per-instance
Requires per-region deployments (Module O-9)
The honest summary of that table: silo optimises for isolation and per-tenant operations, and charges you a fleet to operate. Pool optimises for density and operational simplicity, and charges you correctness risk plus noisy neighbours. Bridge looks like a compromise on paper and behaves like the worst of both at scale.
The default for almost every new product is pool, for a reason worth stating plainly: at the start you have no idea which tenants will matter, and pool is the only model where a tenant that signs up and never returns costs you nothing. What you should build into the pool from day one is the ability to promote a tenant out of it later, which is this module's last section.
Silo's Bill Is a Migration Queue and a Cost Floor
Silo is genuinely the strongest isolation model, and the arguments for it are real: a tenant's data can be restored, exported, or deleted independently, a runaway query hurts one customer, and an enterprise buyer's security questionnaire gets easy answers. Regulated and residency-constrained deployments (Module O-9) sometimes leave you no choice.
Two costs dominate, and both scale with tenant count rather than with data volume.
The migration queue. Adding a column across 3,000 databases is 3,000 migrations. Each one can fail independently — a lock timeout on one busy tenant, a disk-full on another, a schema that drifted two years ago because someone ran a hotfix by hand. Now your deploy pipeline has partial-success semantics, which means every application version must tolerate both the old and new schema simultaneously — not just briefly during a rollout, but for as long as the slowest tenant takes. That's the expand/contract discipline from Module O-4 applied for days instead of minutes, and it needs real machinery: a migration orchestrator with per-tenant state, retries, concurrency limits so you don't saturate the shared control plane, and a dashboard showing which tenants are on which schema version. Teams underestimate this consistently, because at 20 tenants a for loop works fine and the pain arrives around the point where it's most expensive to change course.
The cost floor. A silo has a minimum price whether or not the tenant uses it: the instance, its storage baseline, its replica if you promise HA, its backups, and its share of monitoring cardinality. Ten thousand small tenants at any nonzero monthly floor is a business-model problem, not an engineering one. This is exactly why silo tends to appear as a tier — the enterprise plan — rather than as the whole architecture.
Why this matters in production: the silo model relocates your scaling problem rather than removing it. Instead of one large database with a hot partition you now have a fleet with a version-skew problem, and fleet management is the harder discipline. Module F-9's rule holds: scaling one tier relocates the bottleneck, and here the new bottleneck is your control plane.
Bridge Doesn't Break the Data Model, It Breaks the Tools
Schema-per-tenant is seductive. You get namespace isolation, pg_dump --schema=tenant_4821 for a per-tenant export, no tenant_id on every table, and one instance to operate. It works well at tens of tenants and it degrades in a way that's hard to reverse.
Catalogue pressure. Every schema multiplies the system catalogue: rows in pg_class and pg_attribute for every table, index, and column, in every schema. Forty tables per tenant across 3,000 tenants is 120,000 tables before indexes. The consequences are indirect and therefore confusing:
Each backend process builds and caches metadata for the relations it touches, so per-connection memory rises with how many schemas a connection has visited.
Autovacuum has to consider every relation, and catalogue tables themselves bloat under heavy DDL — which means onboarding load degrades planning performance for everyone.
Anything that scans the catalogue gets slow: \dt, information-schema queries, ORM introspection at boot, migration tools that enumerate tables before deciding what to do. Application startup can end up dominated by catalogue reads.
Backup and restore stop being routine. A single logical dump of the whole database walks every relation in every schema; the operation that took minutes at 50 tenants doesn't take proportionally longer, it takes disproportionately longer, and the restore is worse. Physical backups still work and are what you should rely on, but they restore everything or nothing — so the one advantage you adopted bridge for (per-tenant restore) is available only through the logical path that has become impractical.
Connection pooling breaks in a specific, sharp way. Bridge routing usually works by setting search_path per request. Under a transaction-pooling proxy (Module P-4), a server connection is handed to a different client after each transaction, so any session-level state you set outside a transaction can leak into another tenant's next query:
ts
That works, and it costs you: every tenant-scoped read is now a transaction, prepared-statement caching is per-search_path so plan caches multiply, and you have surrendered the option of session-level pooling. The alternative — a pool per tenant — multiplies Postgres's default max_connections of 100 against a per-connection cost that is a forked backend process, and exhausts the server long before it exhausts your tenant list.
The judgement: choose bridge only if you have a firm ceiling on tenant count — a few hundred, contractually — and you actively want per-schema dumps. If the product's growth story involves self-service signup, bridge is a migration you will have to do later under pressure.
Pool's Failure Mode Is a Single Missing Predicate
In the pool model the tenant boundary is WHERE tenant_id = $1. That predicate is now a correctness requirement in every query, in every service, in every background job, in every ad-hoc script an engineer runs during an incident. One omission and the endpoint returns everyone's data — usually a list endpoint, usually added in a hurry, usually discovered by the customer.
Code review and integration tests reduce the rate. They do not change the shape of the risk, because they rely on remembering something. The same four failure modes Module F-13 gave for application-level validation apply verbatim: a second writer, a bulk job, an admin script, and a new code path that forgets.
Row-level security is the structural fix. It moves the predicate from every query into the table definition, where forgetting is no longer possible:
sql
USING filters what is visible to reads, updates, and deletes; WITH CHECK stops a write from creating a row belonging to another tenant. Both are needed — a policy with only USING lets an insert plant a row you can then no longer see.
The tenant is supplied per request, and it must be transaction-scoped for exactly the pooling reason above:
ts
What RLS costs, stated honestly:
The policy is a predicate the planner must satisfy on every access. With the right indexes it collapses into the same index scan you'd have written by hand; with the wrong ones it turns a seek into a filter over the whole table. Which means: every index on a pooled table should lead with tenant_id — (tenant_id, created_at DESC), not (created_at DESC) — by Module F-13's leftmost-prefix rule. This is the single most common pool performance mistake.
Superusers and roles with BYPASSRLS ignore policies. Your migration role probably needs to; your application role must not have it.
Debugging gets less obvious. A query that returns nothing because app.tenant_id was never set looks like missing data, not like a security control working correctly. Fail loudly: make the setting's absence an error rather than a default.
It is a defence in depth, not an authorisation system. RLS faithfully enforces whatever tenant id you hand it. Deciding that the caller is entitled to that id is Module A-13's problem.
A useful middle option inside pool: partition the largest tables by tenant_id (list partitioning for named large tenants, hash for the long tail). You keep one schema and one migration, and you regain some silo-like properties — partition-level maintenance, cheaper bulk deletion of a departing tenant, and partition pruning that keeps a whale's data out of everyone else's index pages. The cost is the usual partitioning cost from Module P-6: every query wants the partition key, and cross-tenant queries fan out across all partitions.
Noisy Neighbours Are the Real Subject
Isolation models are a one-time decision. Noisy neighbours are a permanent operating condition, and they are what actually pages you.
The mechanism is always the same: tenants share a finite resource, one tenant's demand spikes, and the resource is consumed by whoever asked first. Every shared resource in your stack is a candidate — connection-pool slots, database CPU and buffer cache, disk IOPS, WAL throughput, background-worker capacity, Redis memory and its single-threaded command loop, outbound API quota to a third party you both depend on. Note that this is Module F-13's one-slow-query cascade with a new twist: the query is legitimate, it belongs to a paying customer, and it is destroying your p99 for everyone else.
Tenant traffic is reliably power-law distributed. A small number of tenants generate most of the load, the largest tenant is often larger than the next ten combined, and the distribution shifts when one customer's own product goes viral. Any capacity plan built on average tenant size is wrong in the direction that hurts.
Four levers, in the order they usually pay off.
1. Per-tenant rate limits and quotas. The boundary is the natural limit key, so the limiter's counters are keyed by tenant rather than by IP or user. Two distinct things are needed: a rate limit (requests per second, to protect latency) and a quota (units per month, to protect cost and to make plan tiers mean something). The mechanics — token bucket versus sliding window, where the counters live, what happens when the limiter itself is down — are Module P-18. The design point here is that rate limits alone are insufficient, because request count is a poor proxy for cost: one cheap-to-issue report request can consume more database time than ten thousand key lookups. You also need to limit expensive work by its own units — concurrent exports per tenant, rows scanned, job slots held.
2. Dedicated capacity for whales. Once a tenant is large enough that its variance is visible in your aggregate metrics, stop pooling its compute. In escalating order of cost: a dedicated worker pool for its background jobs, then a dedicated application deployment, then its own shard or replica, then a full silo. Each step buys isolation and charges you a fleet-management increment plus a routing layer that must know which tenants are special. The routing table is the part to design deliberately — a tenants.shard_id column consulted at request entry, not hostname conventions scattered through config.
3. Tenant-aware connection budgets. This is the one most often missed, and it's the one that turns a single tenant's slow report into a full outage. If a pool of 20 connections per instance is first-come-first-served, one tenant with a burst of slow queries takes all 20 and every other tenant queues behind it (Module P-4). The fix is a bulkhead (Module A-7) at the tenant granularity: cap how many connections or in-flight queries any one tenant can hold, so the worst case for others is degradation rather than starvation.
ts
The cost is real and should be named: a tenant that would have succeeded by waiting now gets an error, and the cap is per-instance rather than global unless you centralise the counter (which adds a network round trip to every query). Per-instance caps with a global limiter for the expensive paths is the usual compromise.
4. Per-tenant query timeouts.statement_timeout set per transaction, differentiated by tenant tier and by workload class — a few seconds for interactive requests, longer for a batch export running on a dedicated worker. This converts "one tenant holds a connection for five minutes" into "one tenant's request fails", which is a far better outage. It is close to free and routinely absent.
Why this matters in production: without per-tenant limits, your effective SLO is set by your least disciplined customer. With them, a tenant that misbehaves degrades itself first, which is both the correct blast radius and — because the limit produces a specific, attributable error — the fastest possible diagnosis. Module O-2's error budget is only defensible if a single tenant cannot spend it.
All four levers depend on measuring per tenant, which has its own cost: tagging metrics with tenant_id multiplies time-series cardinality by tenant count, and metrics systems price cardinality (Modules O-1, O-8). The workable pattern is full per-tenant detail for the top tenants by usage, aggregate buckets for the long tail, and per-tenant attribution in logs and traces where cardinality is cheaper than in metrics.
Promoting a Tenant Out of the Pool Without Downtime
Eventually one tenant outgrows the pool: it dominates your largest tables, its reports move the shared p99, or it buys a contract requiring its own database or its own region. You need to move one tenant's data out of shared tables and into a silo while it keeps writing.
The naive version is a maintenance window: stop writes, export the tenant's rows, import, flip routing. It works, it's honest, and for a small tenant on a weekend it may be the right answer — the cost is a scheduled outage for one customer, which is sometimes cheaper than the machinery below. For a large tenant, the export alone outlasts any window you'd be allowed.
Two approaches that avoid the window.
Dual-write. The application writes every change for that tenant to both the pool and the new silo during the transition. Backfill historical rows in batches behind the live writes, then verify, then cut reads over, then stop writing to the pool. This is simple to describe and it inherits the dual-write problem from Module F-14 in full: two writes without a shared transaction can partially fail, so the two copies diverge silently under exactly the conditions you can't reproduce — a crash between the writes, a timeout on the second one, out-of-order concurrent updates to the same row. If you take this path you need a reconciliation job comparing the two copies continuously, not once, and it should be able to repair as well as report.
CDC-based. Stream the tenant's changes out of the source database's replication log and apply them to the target. The write path stays single-destination, so there is no divergence window to reason about, and the log gives you ordering for free. The steps:
Create the target silo with the same schema. Start the change stream from a known log position before the snapshot you're about to take.
Copy the tenant's rows — WHERE tenant_id = X across each table — in batches sized to avoid long-running transactions (Module F-13: a long transaction blocks vacuum database-wide, and the pool is shared, so this batching protects other tenants).
Apply buffered changes from the stream position onward. The apply must be idempotent, because you will replay: upsert by primary key, and use the source's version or log position to reject out-of-order applies (Module P-16).
Wait for replication lag to reach steady state — seconds, not minutes.
Verify: row counts per table, and checksums over the rows. Reconcile the differences before you trust it.
Cut over reads. Then briefly fence writes for this tenant (reject with a retryable error for a few seconds, which clients retry through per Module A-6), confirm the stream has drained, and flip the routing entry.
Keep the pool's copy read-only for a rollback window, then delete it. Deleting the tenant's rows from the shared tables is itself a batched job that generates substantial bloat.
The mechanics of change streams — snapshot-then-stream, log retention, at-least-once delivery, and what happens when the consumer falls behind — are Module P-23. The cutover discipline, feature-flagged routing, and expand/contract sequencing are Module O-4. This is through-line 2 arriving at a tenant-shaped problem: CDC is how data leaves a database, whether the destination is a warehouse or another tenant's new home.
The cost of building this: a routing indirection on every request, a tenant-aware data-access layer that can talk to either topology, and two deployment shapes to test. Which is why the recommendation is to build the routing indirection on day one, while it is a single function returning a single connection, and defer everything else. Retrofitting tenant routing into a codebase that assumed one database is the expensive version of this work.
Where This Shows Up in Your Stack
PostgreSQL: RLS with FORCE ROW LEVEL SECURITY and both USING and WITH CHECK; set_config(..., true) rather than SET so the tenant id dies at COMMIT; every index on a pooled table led by tenant_id; list/hash partitioning on tenant_id for the largest tables; per-transaction statement_timeout by tenant tier. If you are on bridge, watch pg_class row counts and catalogue bloat as first-class capacity metrics.
Node.js: one withTenant() wrapper that every tenant-scoped query passes through — it sets the tenant, the timeout, and the concurrency cap in one place, and it is the only realistic way to make the boundary unforgettable. Never carry the tenant id in a module-level variable; use an AsyncLocalStorage context so a concurrent request can't read another's.
Redis: key-prefix every tenant (t:{tenantId}:...) so eviction, scanning, and per-tenant flush are all possible; remember that Redis memory is a shared resource, so one tenant caching large objects evicts everyone else's hot keys (Module F-12's LRU flood, with a tenant attached). For a whale, a separate instance or a dedicated logical database.
pgBouncer / connection proxies: transaction pooling is incompatible with session-level search_path or SET, which is the mechanism by which bridge and pooling collide. Decide which you need before you have both (Module P-4).
Kafka: partition-key by tenant when ordering per tenant matters, and accept that one large tenant makes one partition hot — the consumer-group rebalance and the hot-partition remedy are Module P-15.
Summary
Multi-tenancy is a partitioning and capacity problem first — where the tenant boundary sits in the storage layout, and how you stop one tenant eating a shared resource. Proving the caller owns the tenant is a separate problem (Module A-13).
Silo isolates best and charges you a fleet — N migrations with partial-success semantics, and a per-tenant cost floor that makes small tenants unprofitable. It works well as a tier, rarely as the whole architecture.
Bridge breaks tools rather than models — catalogue pressure from tens of thousands of relations, logical dumps that stop being practical, and a direct collision between per-request search_path and transaction-mode pooling. Choose it only with a hard ceiling on tenant count.
Pool is the right default and its failure mode is one missing predicate — a query without tenant_id returns everyone's data, and code review only lowers the rate.
Row-level security is the structural fix — FORCE ROW LEVEL SECURITY, both USING and WITH CHECK, tenant set transaction-scoped so pooling can't leak it. It costs planner work, so every pooled index must lead with tenant_id.
Noisy neighbours are the permanent condition, not the one-time decision. Tenant load is power-law distributed; a plan built on the average tenant is wrong where it hurts.
Four levers: per-tenant rate limits and quotas (Module P-18, because request count is a poor proxy for cost), dedicated workers or shards for whales, tenant-aware connection budgets that fail fast instead of queueing (Modules P-4, A-7), and per-tenant statement_timeout.
Per-tenant measurement costs metric cardinality — full detail for top tenants, buckets for the tail, attribution in logs and traces (Modules O-1, O-8).
Promote a tenant out of the pool with CDC, not dual-write — dual-write inherits Module F-14's divergence problem and needs continuous reconciliation; CDC gives ordering and a single write path (Modules P-23, O-4). Build the routing indirection on day one; defer the rest.
The next module, Specialty Datastores: Wide-Column, Graph, and Time-Series, takes the tenant boundary you've just chosen and asks what happens when the workload no longer fits a relational engine at all — wide-column stores that make tenant_id part of a mandatory partition key, and time-series engines where per-tenant retention and downsampling become the isolation mechanism.
Knowledge Check
A team adopted schema-per-tenant three years ago and now runs roughly 3,000 schemas on one Postgres instance. Deploys take four hours because migrations loop over every schema, the nightly logical dump no longer finishes, ORM startup spends most of its time introspecting, and moving to a transaction-mode connection proxy broke tenant routing. What does the module say the situation is?
A pooled multi-tenant application ships a new list endpoint whose query omits WHERE tenant_id, and one customer sees another's records. Tests and code review both missed it. Which response does the module call the structural fix, and what does it cost?
One large tenant starts running heavy exports during business hours. Its own requests are within the per-tenant request-per-second limit, yet p99 latency rises for every other tenant and pool-wait time dominates the traces. What does the module identify as the gap, and the fix?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// BROKEN under transaction-mode pooling: SET is session-scoped, and the// pooler may hand this server connection to another tenant afterwards.await pool.query(`SET search_path TO tenant_${tenantId}`);await pool.query('SELECT * FROM invoices WHERE status = $1',['open']);// Correct: keep it inside one transaction so the setting is scoped to it,// and SET LOCAL is reverted at COMMIT before the connection is reused.await pool.transaction(async(tx)=>{await tx.query(`SET LOCAL search_path TO tenant_${tenantId}`);return tx.query('SELECT * FROM invoices WHERE status = $1',['open']);});
ALTERTABLE invoices ENABLEROWLEVEL SECURITY;-- Without FORCE, the table's owner bypasses policies entirely — and app roles-- are frequently the owner, which silently disables the whole mechanism.ALTERTABLE invoices FORCEROWLEVEL SECURITY;CREATE POLICY tenant_isolation ON invoices
USING(tenant_id = current_setting('app.tenant_id')::bigint)WITHCHECK(tenant_id = current_setting('app.tenant_id')::bigint);
exportasyncfunctionwithTenant<T>( tenantId:string,fn:(tx: Tx)=>Promise<T>,):Promise<T>{return pool.transaction(async(tx)=>{// set_config(..., true) is the SET LOCAL equivalent: reverted at COMMIT,// so a pooled connection can never carry one tenant's id into the next.await tx.query('SELECT set_config($1, $2, true)',['app.tenant_id', tenantId]);await tx.query('SET LOCAL statement_timeout = $1',[tenantTimeoutMs(tenantId)]);returnfn(tx);});}
// Per-tenant concurrency cap in front of the shared pool. The point of the// reject path: a tenant hitting its own ceiling must fail fast rather than// queue, because queueing is how one tenant consumes the shared timeout budget.const inFlight =newMap<string,number>();constCAP=4;// of a 20-connection pool: no tenant can take more than a fifthexportasyncfunctionacquire<T>(tenantId:string,fn:()=>Promise<T>):Promise<T>{const n = inFlight.get(tenantId)??0;if(n >=CAP)thrownewTenantConcurrencyExceeded(tenantId,CAP); inFlight.set(tenantId, n +1);try{returnawaitfn();}finally{const m =(inFlight.get(tenantId)??1)-1; m ===0? inFlight.delete(tenantId): inFlight.set(tenantId, m);}}