Django Advanced

Multi-Tenant Django: Schema-per-Tenant vs Row-Level Isolation, Routing, and Data Safety

Serve many customers from one Django codebase without leaking data between them. Compare shared-schema, schema-per-tenant, and database-per-tenant; implement tenant routing middleware; and lock down the query layer.

DjangoZen Team Jun 06, 2026 17 min read 3 views

Every B2B SaaS becomes multi-tenant the moment you sign a second customer. The architecture you pick here is expensive to reverse, and the failure mode — one tenant seeing another's data — is the kind of bug that ends contracts and triggers breach disclosures. This is a thorough tour of the three isolation models, how to route each request to the right tenant, and how to make data leaks structurally impossible rather than merely unlikely.

What "tenant" actually means

A tenant is the unit of data isolation — usually a customer organization, not an individual user. Users belong to a tenant; data belongs to a tenant; billing attaches to a tenant. Getting this boundary right in your data model from day one is what makes everything downstream tractable. The question this tutorial answers is: how strongly do you isolate one tenant's data from another's, and how do you guarantee that isolation holds on every code path, forever.

The three isolation models

ModelIsolationCost / opsBest for
Shared schema (tenant_id column)Logical onlyCheapest, one DBMany small tenants, freemium
Schema-per-tenant (PG schemas)StrongModerate, N schemas to migrateMid-market, light compliance
Database-per-tenantPhysicalHighest, N databasesEnterprise, regulated, data residency

The trend with scale is to move up this table as tenants get larger and contracts get stricter. Many mature SaaS run a hybrid: shared schema for the free and small tiers, a dedicated schema or database for enterprise accounts that contractually demand it. Decide deliberately — retrofitting strong isolation onto a shared-schema app with millions of rows is a multi-quarter project, so think one tier ahead of where you are.

Shared schema, done safely

Every tenant-owned row carries a tenant foreign key. It is the cheapest model and the easiest to leak from: one forgotten .filter(tenant=...) anywhere in the codebase exposes everyone's data. The defense is to make the safe path the default path, so a developer has to go out of their way to write an unscoped query.

Stash the current tenant in context-local state, set once per request, and build a manager that cannot return cross-tenant rows:

from threading import local
_state = local()

def set_current_tenant(t): _state.tenant = t
def get_current_tenant():  return getattr(_state, "tenant", None)

class TenantManager(models.Manager):
    def get_queryset(self):
        qs = super().get_queryset()
        tenant = get_current_tenant()
        return qs.filter(tenant=tenant) if tenant else qs.none()

class TenantModel(models.Model):
    tenant = models.ForeignKey("customers.Tenant", on_delete=models.CASCADE)
    objects = TenantManager()
    class Meta:
        abstract = True

Two details make this robust. First, default to .none() when no tenant is set — fail closed, never open. A bug that forgets to set the tenant should return nothing, not everything. Second, keep an unscoped manager available only for admin and ops code, named loudly (all_tenants = models.Manager()) so it stands out in review and can never be reached by accident.

Defense in depth: PostgreSQL Row-Level Security

Application-layer scoping is only as good as your discipline. For a hard backstop, PostgreSQL Row-Level Security enforces tenant scoping inside the engine itself — even a raw SQL query or a bug in your ORM layer cannot escape it. Set a session variable per request and write a policy:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::int);
# per request, on the connection:
with connection.cursor() as c:
    c.execute("SET app.tenant_id = %s", [tenant.id])

Now the database is the final arbiter. Belt and suspenders — the manager keeps your queries clean and fast, RLS guarantees that even a mistake can't leak. For regulated products this dual enforcement is often an audit requirement, not a nicety.

Schema-per-tenant with django-tenants

django-tenants maps a subdomain to a PostgreSQL schema and switches the connection's search_path per request. Each tenant's tables live in their own namespace, so a missing filter cannot leak across tenants — the rows are not even visible on the connection. You split your apps into shared (the tenant registry, billing, anything global) and tenant (everything customer-owned):

DATABASE_ROUTERS = ["django_tenants.routers.TenantSyncRouter"]
TENANT_MODEL = "customers.Client"
TENANT_DOMAIN_MODEL = "customers.Domain"

SHARED_APPS = ["django_tenants", "customers", "django.contrib.admin"]
TENANT_APPS = ["orders", "catalog", "billing"]

The cost is operational: migrations run across every schema (migrate_schemas instead of plain migrate), connection setup is heavier, and some third-party packages assume a single schema. The payoff is isolation you do not have to police in code review on every pull request — the boundary is enforced by the database namespace, not by remembering to add a filter.

Database-per-tenant

The strongest model gives each tenant its own database, selected at request time by a database router. You get physical isolation, per-tenant backups and point-in-time restores, the ability to place a tenant's data in a specific region for residency requirements, and a clean story for "delete everything about this customer." The price is real operational weight — N databases to provision, migrate, monitor, back up, and connection-pool — so reserve it for enterprise accounts that require it, often as a paid tier rather than your default.

Tenant routing middleware

Whatever the model, resolve the tenant once, early, and set it for the request's lifetime. Resolve from the subdomain, a verified custom domain, or an authenticated token — never from a value the client can put in the request body:

class TenantMiddleware:
    def __init__(self, get_response): self.get_response = get_response

    def __call__(self, request):
        host = request.get_host().split(":")[0]
        sub = host.split(".")[0]
        tenant = Client.objects.filter(subdomain=sub).first()
        if tenant is None:
            return HttpResponseNotFound("Unknown tenant")
        request.tenant = tenant
        set_current_tenant(tenant)
        try:
            return self.get_response(request)
        finally:
            set_current_tenant(None)   # CRITICAL: never leak to next request

The finally block is not optional. Workers handle many requests in sequence, and thread-local state that is not cleared will bleed one tenant's context into the next request served by that worker — the single worst bug in multi-tenancy. Clear it unconditionally, even on error.

Binding users to tenants

Routing tells you which tenant the request is for; authentication must confirm the user belongs to it. A user authenticated against tenant A who sends a request to tenant B's subdomain must be rejected, or you have built a trivial cross-tenant escalation. Check membership right after resolving both: if request.user.tenant_id != request.tenant.id: raise PermissionDenied. This is easy to forget precisely because each piece works in isolation.

Cross-tenant gotchas outside the request

The request path is the easy part. The leaks hide in everything that runs without a request and therefore without your middleware:

  • Celery tasks run in a separate process with no request and no thread-local. Pass tenant_id explicitly as a task argument and re-establish the context at the top of the task — then clear it in a finally, exactly like the middleware.
  • Caching — always namespace cache keys with the tenant ID. A key like "dashboard_stats" will serve tenant A's numbers to tenant B. Make it f"t{tenant.id}:dashboard_stats", and audit every cache.set in the codebase for this.
  • File storage — prefix upload paths per tenant (tenants/{id}/...) and check ownership on download, or a guessed URL leaks files.
  • Signals and management commands — they run without a request; set the tenant explicitly before any tenant-scoped query.
  • The Django admin — superusers see across tenants by default. Scope querysets in ModelAdmin.get_queryset deliberately, or you have built a leak into your own tooling.
  • Background reports and exports — the classic place a nightly job emails tenant A a CSV containing tenant B's rows.

Testing isolation

Write an explicit test that proves tenant B cannot see tenant A's data through your normal query paths — and keep it in CI forever:

def test_tenant_isolation():
    a, b = TenantFactory(), TenantFactory()
    set_current_tenant(a); secret = OrderFactory()        # belongs to A
    set_current_tenant(b)
    assert not Order.objects.filter(pk=secret.pk).exists()  # B can't see it
    set_current_tenant(None)

def test_no_tenant_returns_nothing():
    set_current_tenant(None)
    assert Order.objects.count() == 0       # fail closed, not open

These two tests catch the regressions that matter most: a cross-tenant leak, and a missing context returning everything instead of nothing. Add one per sensitive model.

Performance considerations

In shared-schema, index every tenant-scoped table on (tenant_id, ...) with the tenant column first, so every query starts by narrowing to one tenant — without it, large tenants table-scan and noisy-neighbor effects appear. In schema-per-tenant, watch connection counts: each schema switch is cheap but per-tenant connection pools multiply, so front everything with a pooler. In database-per-tenant, connection management is the whole game — you cannot hold an idle pool open to a thousand databases, so use a router plus a pooler that opens connections on demand.

Migrating from single- to multi-tenant

If you are retrofitting, the safe sequence is: add a nullable tenant FK, backfill it for existing rows, make it non-null, swap in the tenant manager, then sweep the codebase for any .objects usage that should be scoped. Do it behind a feature flag and keep the old unscoped path available until you have verified every query path under real traffic. Rushing this is exactly how leaks ship — the bug is invisible until the wrong customer sees the wrong data.

Onboarding and provisioning a new tenant

Signing up a customer is not just inserting a row — it is a small orchestration, and how you build it depends on your isolation model. In shared-schema you create the tenant record and seed its default data (roles, settings, a starter project) inside one transaction, so a half-provisioned tenant never exists. In schema-per-tenant you additionally create the PostgreSQL schema and run migrations against it; django-tenants does this when you save the tenant, but it can take a few seconds, so do it in a background task and show the user a "setting up your workspace" state rather than blocking the signup request. In database-per-tenant you provision an entire database, which is slow enough that it is always asynchronous and usually gated behind a sales process.

Whichever model you use, make provisioning idempotent and resumable. Networks and workers fail midway, and a tenant stuck half-created is a support ticket and a billing dispute. Key the provisioning steps so re-running them is safe, record progress, and verify the finished state before you mark the tenant active and route real traffic to it. Treat tenant teardown with the same care — deleting a tenant should be a deliberate, reversible-for-a-window operation, not a cascade you can trigger by accident.

Per-tenant configuration and feature flags

Tenants are never truly identical. One wants SSO, another a custom logo, a third is on a plan that unlocks an extra module. Model this as per-tenant configuration rather than scattering if tenant.name == ... checks through the codebase — that pattern rots instantly. A small settings model keyed by tenant, layered over global defaults, lets you answer "is this feature on for this tenant?" in one place. Combine it with the feature-flag pattern so you can roll a capability out tenant by tenant: enable it for a friendly customer first, watch their usage and errors, then widen. This is also how you safely run different tiers from one codebase — the plan determines which flags resolve true.

Billing and usage metering per tenant

Multi-tenant and billing are joined at the hip, because the tenant is the unit you charge. Decide early what you meter — seats, API calls, storage, active records — and record it per tenant as it happens, not by reconstructing it from logs at month end. A usage event table keyed by tenant, aggregated nightly, gives you both the number you invoice and the data you need to enforce plan limits in real time. Wire it to your payment provider's subscription model so plan changes, proration, and overage flow automatically. Getting metering right from the start avoids the painful retrofit of trying to figure out, months later, what each customer actually used.

Tenant data export and deletion

Enterprise and regulated customers will contractually require two things: give me all my data on demand, and delete all my data when I leave. Both are dramatically easier the stronger your isolation. In database-per-tenant, export is a dump and deletion is a drop. In schema-per-tenant, both scope cleanly to one namespace. In shared-schema you must walk every tenant-scoped table — which is exactly why your models all inherit one tenant FK and you keep a registry of tenant-owned models, so export and deletion iterate that registry rather than relying on a developer to remember every table. Test the deletion path; an incomplete delete is a compliance violation waiting to be discovered in an audit.

Observability per tenant

When something breaks, "the app is slow" is far less useful than "tenant 412's dashboard is slow." Tag your logs, metrics, and error reports with the tenant ID so you can slice every signal by customer. This turns vague reports into precise diagnosis, surfaces noisy-neighbor problems (one tenant's heavy usage degrading others on shared infrastructure), and lets you proactively reach out to a customer hitting errors before they open a ticket. It also makes capacity planning concrete: you can see which tenants drive load and price or shard accordingly.

Summary

Start with shared-schema plus a fail-closed tenant manager, and add PostgreSQL Row-Level Security for engine-level enforcement, when you have many small customers; move to schema-per-tenant or database-per-tenant as isolation, compliance, or data-residency demands grow, often as a paid enterprise tier. Whatever you choose, the rules are constant: resolve the tenant once per request from a non-spoofable source, confirm the user belongs to that tenant, clear the context in a finally, namespace every cache key, re-establish tenant context inside Celery tasks and commands, scope the admin, index tenant-first, and keep permanent isolation tests in CI. The cheapest bug to prevent here is the most expensive one to have.