Filter concepts by levelShowing all levels.

Django · Multi-Tenancy

Multi-Tenancy

One deployment, many customers — and a filter that must not be forgettable

  • A subdomain is a claim, not an identitycheck it against membership, then 404 — a 403 confirms the customer exists
  • A filtering manager does not cover relationsDjango's docs: base managers aren't used when accessing a relationship
  • Scope at the lookupget_object_or_404(Invoice, pk=pk, tenant=request.tenant) — then traversal is safe
  • The tenant belongs in every cache keyotherwise the first tenant to populate it serves all the others
  • Audit needs three identitiestenant acted on, actor, and impersonator — or support actions read as the customer

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

The three models, on one dial
PropertyShared schemaSchema per tenantDatabase per tenant
separation isa `tenant_id` columna PostgreSQL schemaa whole database
a missing filter returns**another tenant's rows**usually nothingnothing — wrong connection
migrationsonceonce per tenantonce per tenant
connection poolsoneoneone per tenant
restore one tenanthard — surgery on shared tablesper-schema dumptrivial
cross-tenant reportinga querya query per schemaan ETL job
practical ceilingvery highhundreds to low thousandstens to hundreds

Where the tenant claim comes from, and what makes it safe

Where the tenant claim comes from, and what makes it safe
SourceShareable URLs?Must be checked against
subdomain (`acme.app.test`)yesthe user's membership of `acme`
path segment (`/t/acme/…`)yesthe same — the path is user input
`X-Tenant-Id` headern/a (API)the token's own tenant claim
the sessionnonothing — the server set it
the user's single membershipnonothing, but breaks for multi-tenant users
Three places the separation can live — and what a forgotten filter returns in each

Shared DB, shared schema

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

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

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

  • 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

python
request.tenant = resolve_tenant(request)   # resolved BEFORE authorisation runs

Two 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.

2.Making the tenant filter impossible to forget

The roadmap states the stake plainly: **a missing tenant filter can become a cross-customer data leak.** So the goal is not to remember the filter — it is to build a system where forgetting it cannot produce another tenant's data. That means four surfaces, not one: the ORM, related-object access, authorization, and cache keys. A tenant-filtering default manager covers the first and, importantly, **not the second** — Django's documentation is explicit that base managers are not used when accessing a relationship.

Key features

  • Base managers "aren't used when querying on related models, or when accessing a one-to-many or many-to-many relationship"
  • So a filtering default manager protects `Model.objects`, not `obj.related_set.all()` or `filter(fk__field=…)`
  • Scope the *lookup*: `get_object_or_404(Invoice, pk=pk, tenant=request.tenant)`
  • A permission is never global — always "this role, on this tenant"
  • Every cache key needs the tenant in it, or the first tenant to populate it serves everyone
  • PostgreSQL RLS: with RLS enabled and no policy, "a default-deny policy is used"

Four surfaces, and what each defence actually covers

Four surfaces, and what each defence actually covers
SurfaceFiltering default managerWhat you must do
`Invoice.objects.all()`coverednothing more
`invoice.line_items.all()`**not covered**scope the lookup of `invoice` itself
`Payment.objects.filter(invoice__ref=…)`**not covered**the join uses the base manager
`get_object_or_404(Invoice, pk=pk)`covered *if* it uses `objects`pass `tenant=request.tenant` explicitly
permission checksirrelevantcheck role **and** membership of this tenant
cache keysirrelevantput the tenant id in every key
raw SQL / `extra()`not coveredRLS, or a reviewed exception

Where to enforce, ranked by what a mistake costs

Where to enforce, ranked by what a mistake costs
LayerForgetting it returnsCost to adopt
nothing — filter by hand each timeanother tenant's rowsnone, and it will be forgotten
filtering default managernothing, *except* through relationslow
scoped lookup helper + managernothing, on every ORM pathlow, once it is the convention
PostgreSQL RLSnothing — default-denyhigher: policies, and a non-owner role
One cache key, two tenants — a leak with no wrong query anywhere

Every database query here is correctly filtered. The leak happens above the ORM, in a cache key that does not name the tenant — which is why tenant-aware caching is listed as its own concern rather than folded into "filter your querysets".

  • Tenant Acme requests the dashboard. The cache key "dashboard_stats" misses, so a correctly filtered query runs and Acme's figures are stored under that key.
  • Tenant Globex then requests the same dashboard. The key "dashboard_stats" hits, and Globex is served Acme's figures without any query running at all.
  • The fix shown below is to include the tenant in the key: "dashboard_stats:acme" and "dashboard_stats:globex" are separate entries.

Command

python
get_object_or_404(Invoice, pk=pk, tenant=request.tenant)

A missing tenant filter is a cross-customer data leak, so make forgetting it impossible rather than unlikely. A filtering default manager covers `Model.objects` and — per Django's own docs — **not** relationship access, so scope at the *lookup* (`get_object_or_404(Invoice, pk=pk, tenant=request.tenant)`) and everything traversed from there is safe. Permissions are always "this role, on this tenant". Put the tenant in every cache key, or the first tenant to populate one serves all the others with no query being wrong. Hold the current tenant in a `ContextVar`, never a global. Where you can, let PostgreSQL RLS make the default deny.

3.Per-tenant limits, configuration and audit

Once tenants share infrastructure they compete for it, so limits stop being an abuse control and become a fairness mechanism: one customer's bulk import must not exhaust the workers everyone else needs. Configuration stops being a setting and becomes a row, because tenants differ — a plan tier, a feature flag, a retention period, a currency. And an audit log stops being optional, because in a shared system the answer to "who saw this?" has to include *which tenant* — an entry without a tenant id cannot answer the only question that matters.

Key features

  • Scope limits per tenant, not per user or IP — one customer is one bill and one blast radius
  • Limit what is actually scarce: worker time and connections, more than request count
  • A separate queue per plan tier bounds damage structurally, where a rate limit only slows it
  • Per-tenant config belongs in a row, not in `settings.py` — otherwise a plan change is a deploy
  • Resolve config as a chain: tenant value → plan default → system default
  • Audit entries need the actor, the target tenant, *and* the impersonator when there is one

What to limit, and why request count is rarely the right one

What to limit, and why request count is rarely the right one
ResourceScope it byBecause
background job slotstenant, per queueone bulk import can starve every other customer
database connectionstenant or planthe pool is the hard ceiling everything shares
export size / row counttenantan unbounded export is an unbounded memory peak
API requeststenant, then userper-user limits let a big customer bypass the cap
storage and retentiontenantcost grows quietly and is never noticed until billing
webhook fan-outtenanta slow endpoint should delay only its own tenant

Three places a per-tenant value could live

Three places a per-tenant value could live
WhereChanging it meansVerdict
`settings.TENANT_OVERRIDES`a deployno — it grows a branch per customer
a column on `Tenant`a database writegood for a handful of stable values
a `TenantConfig` row per keya database writegood when values are many or plan-derived
a feature-flag servicea flag changegood for rollout, not for contractual limits
What moves from code into data once one deployment serves many customers

Request — resolved tenant

request.tenant, verified against membership

Limits, per tenant

job slots, connections, export size — scoped to a customer, not a user

Configuration, per tenant

tenant value → plan default → system default; a row, never a setting

Isolation, on every path

queryset, relation, permission, cache key

Audit, per tenant

actor + target tenant + impersonator — and scoped when read back

Shared infrastructure

one pool, one worker fleet, one bill — which is why the layers above exist

  1. Request — resolved tenant — request.tenant, verified against membership
  2. Limits, per tenant — job slots, connections, export size — scoped to a customer, not a user
  3. Configuration, per tenant — tenant value → plan default → system default; a row, never a setting
  4. Isolation, on every path — queryset, relation, permission, cache key
  5. Audit, per tenant — actor + target tenant + impersonator — and scoped when read back
  6. Shared infrastructure — one pool, one worker fleet, one bill — which is why the layers above exist

Command

python
AuditEntry.objects.create(tenant=target, actor=user, impersonated_by=real_user, action=…)

In a shared deployment, limits, configuration and audit all move from code into data. Scope limits by tenant rather than by user or IP — a hundred users at one customer are one blast radius — and prefer a separate queue per tier over a rate limit, because it bounds damage structurally instead of merely slowing it. Put per-tenant values in rows and resolve them as tenant → plan → system default, so a plan change is a write rather than a deploy. And give every audit entry three identities: the tenant acted on, the actor, and the impersonator when support is acting on a customer's behalf — otherwise the log claims the customer did it.

Remember

  • The roadmap states the stake outright: a missing tenant filter can become a cross-customer data leak.
  • A `ContextVar`, never a global — a module-level variable is shared by every thread, and the leak is load-dependent and unreproducible.
  • Fail closed: a manager with no tenant set returns `.none()`, and an unresolvable tenant is a 404.
  • A permission is never global. It is always this role, on this tenant.

Pro tip

  • Put a second tenant in your test fixtures whose data must never appear. Single-tenant tests pass against every leak in this section.
  • If you adopt PostgreSQL RLS, remember table owners bypass it unless the table is set to `FORCE ROW LEVEL SECURITY` — and Django's migration user is usually the owner.

Next up

  • Auditing
  • Soft Delete
  • State Machines and Workflows

Sources

  • Managers (base vs default) — docs.djangoproject.com/en/5.2/topics/db/managers/
  • Multiple databases — docs.djangoproject.com/en/5.2/topics/db/multi-db/
  • PostgreSQL row security policies — postgresql.org/docs/17/ddl-rowsecurity.html
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.