Multi-tenant architecture in Django
Three patterns for tenant isolation in a Django SaaS — and why I default to the boring one.
TL;DR
- Shared schema + tenant_id column is the boring default. It scales further than you think.
- Schema-per-tenant (search_path tricks) is tempting but introduces operational pain you don't want at MVP stage.
- Database-per-tenant is for compliance edge cases, not your default.
What "multi-tenant" actually means in practice
Multi-tenancy is one of those terms where everyone agrees on the word and nobody agrees on the implementation. For a Django SaaS, the question really is: how strictly do you isolate one customer's data from another's, and at what cost?
Three patterns dominate.
1. Shared schema, tenant_id everywhere
Every row that belongs to a customer carries a tenant_id (or organization_id, workspace_id). Every query filters by it. Foreign keys point inside the tenant.
class Document(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
body = models.TextField()
class Meta:
indexes = [models.Index(fields=["tenant", "-created_at"])]You enforce it three ways:
- A default manager that auto-filters by the current request's tenant.
- A middleware that pins the current tenant on the request and on a thread-local / contextvar.
- Database-level guard rails: composite indexes, and ideally a Postgres row-level security policy for the paranoid case.
This is what I default to. It's boring. It works to thousands of tenants. Backups are one database. Migrations are one operation.
2. Schema-per-tenant
Each tenant gets a Postgres schema. You flip the search_path per request. Libraries like django-tenants automate this.
Pros: stronger isolation, easier to delete a tenant (drop a schema), easier compliance story.
Cons: migrations across N schemas, connection pooler complications, "shared" tables (e.g. plans, feature flags) need careful placement, and you can no longer ask product questions across tenants without writing custom SQL.
I reach for this when a customer asks for it in a contract — never preemptively.
3. Database-per-tenant
Each tenant gets a database. This is the compliance hammer. Enterprise B2B, regulated industries, "our data cannot share a server with anyone else's."
The cost: ops. You're now running fleet-of-databases. Migrations are jobs. Onboarding is a job. Backups are jobs. Don't sign up for this unless someone is paying you specifically for it.
The shared-schema gotchas to actually plan for
The pattern that works long-term still has traps. Things I've been burned by:
- Forgetting the filter once. A single endpoint that calls
.objects.all()instead of.objects.for_tenant(t)will leak. Build it into the manager and lint for raw.objectscalls in code review. - Index ordering.
(tenant_id, created_at DESC)is what you want — not(created_at, tenant_id). Postgres won't tell you it's wrong; the query plan will. - Pagination across tenants. Admin views need to operate cross-tenant. Keep a separate "superuser manager" for these and audit who uses it.
- Soft deletes. Tenant deletion is rarely instant. Have a strategy for "tenant marked deleted, data still around" or you'll regret it during a customer churn event.
What I'd build today
A new Django SaaS, day one, gets:
- A
Tenantmodel with a slug, a name, a plan FK. - A
TenantMembershipmodel joining users to tenants with a role. - A middleware that resolves the current tenant from subdomain or path prefix.
- A
TenantManageron every model that carries tenant-scoped data. - One Postgres database. One schema. Composite indexes on
(tenant_id, ...)everywhere.
That gets you to 10,000 tenants. By then, you'll know which of them is special enough to deserve their own schema.
