1.Identifying the tenant, and the three isolation models
Multi-tenancy is one deployment serving many customers whose data must never mix. Two decisions define it. First, **how you know which tenant a request belongs to** — a subdomain, a path segment, a header, or the logged-in user's membership. Second, **where the separation lives**: one database and one schema with a `tenant_id` column on every row (shared schema), one database with a schema per tenant, or a database per tenant. They trade the same thing in the same direction — the stronger the isolation, the higher the per-tenant cost of running it.
Key features
- Anything from the client — subdomain, path, header — is a claim, and must be checked against membership
- Shared schema: one `tenant_id` column; cheapest, and isolation depends entirely on your code
- Schema-per-tenant: PostgreSQL `search_path` per connection; a forgotten filter usually finds nothing
- Database-per-tenant: real blast-radius and residency isolation, N pools and N migration runs
- Resolve the tenant before authorisation, so every downstream check is already scoped
- Fail closed: no resolvable tenant must mean 404, never "unfiltered"
The three models, on one dial
Where the tenant claim comes from, and what makes it safe
- Shared DB, shared schema — one row set, a tenant_id column
- Cheapest to run — one migration, one pool, one backup
- A missing filter leaks — the query returns another tenant's rows
- Isolation is your code — so it has to be structural, not remembered
- Shared DB, schema per tenant — one connection pool, N sets of tables
- A missing filter finds nothing — search_path scopes the connection
- Migrations run N times — and a failure part-way leaves tenants on two versions
- Catalogue growth — tens of thousands of tables is its own problem
- Database per tenant — the strongest, and the most to operate
- Real blast radius — restore or move one customer alone
- Data residency is possible — this tenant's database, in their region
- N pools, N migrations — and reporting becomes an ETL job, not a query
Command
request.tenant = resolve_tenant(request) # resolved BEFORE authorisation runsTwo decisions. Identification: a subdomain, path or header is a *claim* from the client, and it is only safe once checked against the user's membership — then fail closed with a 404, because a 403 confirms which customers exist. Isolation: shared schema is cheapest and leaks if a filter is forgotten; schema-per-tenant makes a forgotten filter find nothing but multiplies migrations; database-per-tenant gives real blast-radius and residency isolation for N pools and N migration runs. Pick from requirements you have, not ones you imagine — and hold the current tenant in a `ContextVar` cleared in a `finally`, never a global.