Choosing a Multi-Tenant Data Isolation Model
The decision about how tenants share storage gets made in week two by whoever is writing the first migration, and it is still shaping the company five years later. That is an unfair amount of leverage for one early choice, which is why multi-tenant data isolation deserves an explicit decision rather than a default.
The industry has settled on three broad models, usually called silo, bridge, and pool. They are not a maturity ladder where pool is the grown-up answer. They are a genuine trade between blast radius, cost per tenant, and how much operational pain you are willing to own.
Silo, Bridge, Pool
Silo gives every tenant their own database, sometimes their own everything. Isolation is total: a bad query cannot cross a tenant boundary because there is no shared surface to cross. Compliance conversations get short, per-tenant restores are trivial, and a runaway tenant only hurts themselves. But your migration story is now “run this against 400 databases and hope”, your connection pool maths gets ugly fast, and the fixed cost per tenant means a free-tier signup is genuinely expensive.
Pool puts every tenant in shared tables with a tenant_id column. It is by far the cheapest and simplest to operate — one schema, one migration, one connection pool, and onboarding a tenant is an INSERT. The price is that isolation is now entirely a property of your code being correct, forever, in every query anyone ever writes. One forgotten WHERE tenant_id = ? is a cross-tenant data leak.
Bridge sits between: one database, a schema per tenant. Shared infrastructure, but a real namespace boundary. It is a reasonable middle ground into the low hundreds of tenants, though schema-per-tenant stops being funny somewhere past that — Postgres will hold thousands of schemas, but your migration tooling and pg_dump will start expressing opinions.
Most Companies Should Start Pooled, and Plan to Escape
Here is the pragmatic recommendation, stated plainly: start pooled unless a specific customer contract or regulation forces otherwise, but write the code so a tenant can be lifted out later without an archaeology dig.
The reason is that the expensive mistake is rarely picking pool. It is hard-coding the assumption that every tenant lives in the same database — connection acquisition scattered through the code, a global datasource, joins that silently span tenants. Route every database call through a single tenant-aware layer from day one, and the day a large customer demands their own instance becomes an infrastructure task rather than a rewrite. This is the same discipline as keeping infrastructure behind ports in our hexagonal architecture guide: the shape of the storage should not leak into the domain.
Row-Level Security Turns a Convention Into a Constraint
If you pool, do not rely on discipline. PostgreSQL row-level security pushes the tenant predicate into the database, so a query that forgets its filter returns zero rows instead of everyone’s.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- The app connects as a role that cannot bypass RLS
CREATE ROLE app_user NOLOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Per transaction, before any query runs:
SET LOCAL app.tenant_id = '5c1f...';
SELECT * FROM invoices WHERE status = 'unpaid'; -- implicitly scoped
Two details are load-bearing and routinely missed. FORCE ROW LEVEL SECURITY matters because a table’s owner bypasses policies by default — omit it and your migration user, or an app connecting as the owner, sails straight through. And WITH CHECK is what stops a tenant writing a row stamped with someone else’s id; USING alone only governs reads.
Equally load-bearing: SET LOCAL, not SET. SET LOCAL is scoped to the transaction and unwinds when it ends, whereas a plain SET persists on the connection — and on a pooled connection that means the next tenant to borrow it inherits the last tenant’s context. That is the exact bug RLS was supposed to prevent, reintroduced by a missing keyword.
Tenant Context Is the Real Source of Bugs
RLS is only as good as whatever sets app.tenant_id, and that plumbing is where things actually break. Resolve the tenant once at the edge from a signed token — never from a client-supplied header or a path segment a user can edit — then propagate it as ambient context.
// One place resolves it, from the verified JWT — never from a raw header
public class TenantFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
var jwt = (Jwt) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
TenantContext.set(UUID.fromString(jwt.getClaimAsString("tid")));
try {
chain.doFilter(req, res);
} finally {
TenantContext.clear(); // MUST run: pooled threads outlive requests
}
}
}
// And one place applies it, on every connection handed to the app
@Aspect @Component
class TenantSessionAspect {
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void bindTenant() {
jdbc.update("SET LOCAL app.tenant_id = ?", TenantContext.require());
}
}
That finally block is not decoration. A ThreadLocal left dirty on a pooled thread will be picked up by the next request that borrows it, and the resulting bug is intermittent, load-dependent, and shows one tenant another’s data. Also audit your async paths specifically: a ThreadLocal does not follow work onto an executor, and virtual threads change the ergonomics again, so anything scheduled or event-driven needs the tenant passed explicitly rather than assumed.
Noisy Neighbours and the Tenant Size Problem
Pooling shares failure as efficiently as it shares hardware. One tenant running a pathological report can exhaust the connection pool and take everybody down with them, and the first you hear of it is a support queue full of unrelated customers.
Rate-limit and quota per tenant, not just globally, and set statement_timeout per tenant role so nobody’s query can run unbounded. Meter connections per tenant too, since a single tenant holding the whole pool is the classic version of this failure. Instrument everything with a tenant label from day one — an aggregate p99 is useless here, because your worst tenant’s disaster averages into a mildly disappointing number and nobody investigates.
Then there is skew, which is where pooled designs age badly. Tenant distributions are power laws: your largest customer will eventually hold more rows than the next thousand combined. Once one tenant dominates a table, index selectivity on tenant_id collapses for everyone else and the query planner starts making bad choices on their behalf. The usual escape is to partition by tenant — the techniques in our PostgreSQL partitioning strategies guide apply directly — or to promote that one whale to a silo and leave everyone else pooled. A hybrid is not an admission of defeat; it is usually the correct end state.
Where Isolation Stops Being a Database Question
Data isolation is necessary and not sufficient. A tenant can be perfectly isolated in Postgres and still share a cache namespace, a search index, a message queue, and a blob prefix — and any one of those leaks just as effectively as a missing WHERE clause. Cache keys are the usual culprit: a key without the tenant in it will serve one tenant’s dashboard to another, and it will do so intermittently enough that nobody believes the bug report.
If total blast-radius control is the actual requirement, the honest answer may be above the database entirely. Running whole tenant groups as independent, self-contained stacks is the model our cell-based architecture guide covers, and it contains failures that no RLS policy can. The PostgreSQL row security documentation is worth reading in full before you commit to RLS as your primary boundary, because its bypass conditions are precisely the things that will bite you.
There is no correct multi-tenant data isolation model, only a correct one for your blast-radius tolerance and your cost per tenant. Start pooled with row-level security, use FORCE and WITH CHECK and SET LOCAL rather than trusting every future query to remember its filter, resolve tenancy once from a signed token and clear it in a finally, and label every metric with the tenant so a noisy neighbour is visible before it is reported. Above all, keep the escape hatch: route storage through one tenant-aware layer, and the day a whale or a regulator demands a silo, you move one tenant instead of rewriting the company.