Schema-per-Tenant Django: Isolation You Cannot Forget to Apply
Row-level tenancy is one missing WHERE clause away from a data breach. Schema-per-tenant moves isolation from something developers remember into something the database enforces - and here is what it costs.
There are three ways to isolate tenants in a SaaS product, and the industry mostly picks the first one because it is the easiest to start with.
Row-level. One schema, a tenant_id column on every table, a filter on every query. Cheapest to run. One forgotten WHERE clause away from showing customer A customer B’s data.
Database-per-tenant. Total isolation. Also a connection pool per tenant, a migration run per tenant, and an operational burden that grows linearly with sales.
Schema-per-tenant. One database, one connection pool, a PostgreSQL schema per tenant. Isolation comes from the search path, so the query does not have to remember anything.
FitsSort uses the third. Here is the honest accounting.
How it works
PostgreSQL’s search_path decides which schema an unqualified table name resolves to. Set it per connection and the same ORM query hits different physical tables:
SET search_path TO tenant_acme, public;
SELECT * FROM members; -- tenant_acme.members
Django middleware resolves the tenant from the request host - subdomain or fully custom domain - and sets the path for the life of the request:
class TenantMiddleware:
def __call__(self, request):
tenant = resolve_tenant(request.get_host())
if tenant is None:
return HttpResponseNotFound()
with connection.cursor() as cursor:
cursor.execute("SET search_path TO %s, public", [tenant.schema_name])
request.tenant = tenant
try:
return self.get_response(request)
finally:
# Connections are pooled and reused. Never hand one back dirty.
with connection.cursor() as cursor:
cursor.execute("SET search_path TO public")
That finally block is the single most important thing on this page. Django reuses connections across requests, and PgBouncer reuses them across everything. A connection returned to the pool with tenant A’s search path still set will serve tenant B’s next request against tenant A’s tables. It is a silent, total, cross-tenant data leak, and it will not show up in any test that runs one request at a time.
If you use PgBouncer in transaction pooling mode, SET does not survive the transaction boundary - you must set the path inside the transaction that uses it, or use set_config(..., true) for transaction-local scope. Getting this wrong produces intermittent failures that look like flakiness and are not.
What you get
Isolation you cannot forget. No tenant_id filter to omit. A junior developer writing Member.objects.all() gets exactly the current tenant’s members, because the other tenants’ members are in schemas that are not on the path.
Per-tenant operations. Restore one customer without touching the others: pg_dump --schema=tenant_acme. Under row-level tenancy, restoring one customer from a backup is a delicate surgical extraction.
Honest query plans. Each tenant’s tables carry their own statistics. Under row-level tenancy, one enormous customer skews the planner’s estimates for everyone, and small tenants get plans optimised for a distribution they don’t have.
Custom domains for free. The tenant is resolved from the host, so app.customer.com is a DNS record and a row, not an architecture change.
What it costs
I would be selling you something if I stopped there.
Migrations multiply. A schema change runs once per tenant. At ten tenants, that is a script. At a thousand, it is a job with progress tracking, resumability, and a very clear answer to “what happens when tenant 400 fails halfway.”
The pattern that survives is: migrations must be backward compatible and independently applicable. Add a nullable column, deploy code that tolerates both shapes, backfill per tenant, then tighten the constraint. Never a migration that requires all tenants to be at the same version simultaneously - because during a rollout, they aren’t.
Cross-tenant queries get hard. “Total active members across all customers” cannot be one query. It is a loop over schemas, or an aggregate table in public that tenants write to. I use the latter - a small reporting schema updated on write - because looping over a thousand schemas for a dashboard is not a plan.
Connection pooling needs discipline. Covered above, and worth repeating: every path change needs a guaranteed reset.
Postgres catalog pressure. Each schema multiplies your table count. A thousand tenants with fifty tables each is fifty thousand tables, and pg_catalog starts showing up in query plans. It works, but \dt becomes a bad idea and autovacuum needs tuning. This is the real ceiling on the approach, and it arrives further out than most products ever get.
Isolation inside a tenant
One schema per customer solves between-customer isolation. It does nothing for the structure inside a single customer - and a gym chain is many branches with staff who must only see their own.
That layer is row-level, and it belongs in the queryset, not the view:
class BranchScopedManager(models.Manager):
def for_request(self, request):
qs = self.get_queryset()
if request.user.is_tenant_admin:
return qs
return qs.filter(branch__in=request.user.accessible_branches)
Views ask for “the members I can see.” The manager decides what that means. Putting it in the view means every new view is a new opportunity to forget.
Authorization is a three-way resolution
The permission question in a SaaS product is never just “is this user allowed?” It’s:
- Does the plan include this feature? Subscription tier - a billing question.
- Does the role permit this action? RBAC - an organisational question.
- Is this record in reach? Branch scoping - a data question.
Any one of the three denying is a deny. Keeping them as three separate, individually-testable checks is what stops “premium feature” and “manager permission” from getting tangled into a single boolean nobody can reason about six months later.
The failure mode when they are tangled: a customer downgrades their plan, and a manager who still has the role keeps access to a feature they are no longer paying for. That’s a revenue bug produced by an architecture decision.
Would I choose it again
For this product, yes - the customers are businesses whose data separation is a contractual expectation, tenant count is in the hundreds rather than the hundreds of thousands, and per-tenant restore has already been needed twice.
For a consumer product with a million accounts, no. Schema-per-tenant at that cardinality is a catalog problem, and row-level tenancy with rigorously enforced query-layer scoping is the right trade.
The question to ask is not “which is best.” It’s: how many tenants, and what is the cost of a leak between them? Those two numbers pick the architecture for you.