Every B2B SaaS is multi-tenant whether the founders use the word or not. The moment a second customer logs in, you are running shared infrastructure that must keep each customer's data, configuration and usage strictly separate. The question is never whether to be multi-tenant — it is where you draw the boundary between tenants, and how hard that boundary is to cross by accident.
Django is an excellent foundation for this. The ORM, the migration system, the request/response cycle and a mature ecosystem (django-tenants, PostgreSQL schemas, row-level security) give you three viable isolation models out of the box. This guide walks through all three the way we'd reason about them on a real project, with the code, and then the performance work that keeps the chosen model fast. If you want the broader scaling context first, our Python web architecture patterns piece sets the scene.
01What multi-tenancy actually means
A tenant is the unit of isolation — usually a customer organisation, sometimes a workspace within one. Multi-tenancy is the practice of serving many tenants from shared application code (and often shared infrastructure) while guaranteeing that no tenant can see, modify or affect another's data.
That guarantee has three dimensions, and a good architecture is explicit about all three:
- Data isolation. Tenant A's queries can never return tenant B's rows. This is the one everyone thinks of, and the one that ends careers when it fails.
- Performance isolation. A "noisy neighbour" — one tenant running heavy reports or holding 90% of the rows — must not degrade everyone else's latency.
- Operational isolation. You can migrate, back up, restore, export or delete a single tenant without touching the others. This matters enormously for compliance (GDPR erasure, data residency) and for incident recovery.
The three models below trade these dimensions off against build cost and operational complexity differently. There is no universally correct answer — only the right answer for your customer profile, your compliance posture and your team size.
02The three isolation models
At a database level, Django multi-tenancy comes down to three patterns. Everything else is a variation on these:
| Model | Isolation | Best when |
|---|---|---|
| Shared schema tenant column on every table | Logical (app-enforced) | Many small tenants; fast onboarding; cross-tenant analytics |
| Schema-per-tenant one Postgres schema each | Strong (DB-enforced) | Hundreds of mid-size tenants needing real separation |
| Database-per-tenant a database (or instance) each | Strongest (physical) | Enterprise deals, data residency, per-tenant SLAs |
The further down the table you go, the stronger the isolation and the higher the per-tenant operational cost. The classic mistake is to over-engineer at the start: a seed-stage SaaS reaching for database-per-tenant "for security" spends its runway building tenant-provisioning plumbing it doesn't need, when a shared schema with proper enforcement would have been both safer and faster to ship. Start as simple as your real requirements allow.
Moving from shared schema to schema-per-tenant later is a real migration project, not a config flag. So while "start simple" is the right default, spend a day up front confirming you don't have a hard requirement (data residency, contractual per-tenant restore) that forces a stronger model from day one. Those requirements are cheap to design for early and painful to retrofit.
03Shared schema, done safely
In the shared-schema model every table carries a tenant foreign key, and every query is filtered by it. It's the simplest model to build, migrate and reason about, and it scales comfortably to thousands of tenants. Its one weakness is that isolation is enforced by your code, not the database — so the entire game is making that enforcement impossible to forget.
The wrong way is to sprinkle .filter(tenant=request.tenant) through every view. One missed filter is a data leak. The right way is to make the tenant filter the default behaviour of the model manager, driven by request-scoped context that no view has to think about:
from contextvars import ContextVar
# contextvars are coroutine-safe — they work correctly under
# both WSGI threads and ASGI async views, unlike threading.local
_current_tenant: ContextVar = ContextVar("current_tenant", default=None)
def set_current_tenant(tenant):
_current_tenant.set(tenant)
def get_current_tenant():
return _current_tenant.get()
A thin middleware resolves the tenant once per request — from the subdomain, a custom domain, or the authenticated user — and stores it in that context variable:
from django.http import Http404
from .context import set_current_tenant
from .models import Tenant
class CurrentTenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0]
subdomain = host.split(".")[0]
try:
tenant = Tenant.objects.get(slug=subdomain, is_active=True)
except Tenant.DoesNotExist:
raise Http404("Unknown tenant")
set_current_tenant(tenant)
request.tenant = tenant
try:
return self.get_response(request)
finally:
set_current_tenant(None) # never leak into the next request
The keystone is a manager whose default queryset is already scoped. Now an ordinary Invoice.objects.all() only ever returns the current tenant's invoices, and a forgotten filter fails safe instead of leaking:
from django.db import models
from .context import get_current_tenant
class TenantManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
tenant = get_current_tenant()
if tenant is None:
# no tenant in context (e.g. a misconfigured job):
# return nothing rather than everything — fail safe
return qs.none()
return qs.filter(tenant=tenant)
class TenantModel(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE,
editable=False, db_index=True)
objects = TenantManager() # scoped by default
all_tenants = models.Manager() # explicit, for admin/analytics
class Meta:
abstract = True
# Django uses _base_manager for related-object fetches and
# cascade deletes — point it at the UNfiltered manager so a
# filtering default manager can't drop valid related rows.
base_manager_name = "all_tenants"
def save(self, *args, **kwargs):
if self.tenant_id is None:
self.tenant = get_current_tenant()
super().save(*args, **kwargs)
One subtlety worth knowing: a filtering default manager is exactly what Django's docs caution against, because Django also uses the first manager as the model's _base_manager for related-object lookups and cascade deletes. Setting base_manager_name = "all_tenants" (above) points those internal operations at the unfiltered manager, so invoice.customer and cascade deletes keep working even outside a request — while ordinary queries through objects stay tenant-scoped.
A manager protects you from accidents, not from a SQL-injection bug or a raw query that bypasses the ORM. For sensitive data, back it with PostgreSQL row-level security: write an RLS policy that reads a runtime setting, and set that setting per request with SET LOCAL app.current_tenant = '…' inside the request's transaction (or SELECT set_config('app.current_tenant', %s, true)). Use SET LOCAL, not a plain session SET — under a transaction-pooling proxy like PgBouncer a session variable would leak onto the next tenant that reuses the connection. Belt and braces: the manager for ergonomics, RLS for the guarantee.
04Schema-per-tenant
When you need stronger separation without a database per customer, PostgreSQL schemas are the sweet spot, and django-tenants implements the pattern cleanly. Each tenant gets its own schema; a public schema holds shared tables (the tenant registry, domains, billing). Per request, the middleware switches the connection's search_path based on the incoming domain, and from that point your models are blissfully unaware that tenancy exists — no tenant column, no scoped manager.
DATABASES = {
"default": {
"ENGINE": "django_tenants.postgresql_backend",
# ... host, name, user, password ...
}
}
DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)
MIDDLEWARE = [
"django_tenants.middleware.main.TenantMainMiddleware",
# ... the rest of your middleware ...
]
# Apps whose tables live in the shared public schema
SHARED_APPS = [
"django_tenants", "tenancy", # Tenant + Domain models
"django.contrib.auth", "django.contrib.contenttypes",
]
# Apps whose tables are created once per tenant schema
TENANT_APPS = [
"billing", "projects", "reports",
]
INSTALLED_APPS = list(SHARED_APPS) + [
a for a in TENANT_APPS if a not in SHARED_APPS
]
TENANT_MODEL = "tenancy.Tenant"
TENANT_DOMAIN_MODEL = "tenancy.Domain"
The win is that isolation is enforced by the database's search_path rather than by your discipline — a missed filter simply cannot return another tenant's row, because that row lives in a different schema this connection can't see. The cost is real, though:
- Migrations multiply. A schema change runs once per tenant schema. With 500 tenants that's 500 executions — fast individually, but it turns a deploy into a job you must orchestrate and monitor (more on this below).
- Cross-tenant analytics get harder. "Total revenue across all tenants" now means querying every schema and unioning the results, rather than one
GROUP BY. - Connection & catalogue overhead. Thousands of schemas bloat the Postgres catalogue and slow some operations; the pattern is happiest in the hundreds-to-low-thousands of tenants, not tens of thousands.
Schema-per-tenant shines for a few hundred mid-sized B2B tenants who each have meaningful data volumes and value the separation. Below that, shared schema is simpler; far above it, the migration and catalogue overhead pushes you back toward shared schema with strong RLS, or toward sharding.
05Database-per-tenant
The strongest isolation is physical: each tenant gets its own database, sometimes its own instance, sometimes its own region. Django supports this directly through its multiple-database support — you register each tenant's database in DATABASES (or build the config dynamically) and route every query with .using(alias) via a database router keyed on the current tenant.
from .context import get_current_tenant
class TenantRouter:
# Apps that live in the shared control-plane DB
SHARED = {"tenancy", "auth", "sessions"}
def _db_for(self, app_label):
if app_label in self.SHARED:
return "default"
tenant = get_current_tenant()
return tenant.db_alias if tenant else None
def db_for_read(self, model, **hints):
return self._db_for(model._meta.app_label)
def db_for_write(self, model, **hints):
return self._db_for(model._meta.app_label)
This is the model enterprise procurement loves: a contractual promise that "your data is in its own database, in your region, and can be exported or destroyed independently" is trivially true. It's also how you satisfy strict data-residency rules — tenant in Frankfurt, database in Frankfurt.
The price is operational. Every tenant is now a database to provision, migrate, monitor, back up and patch. Connection management becomes a genuine constraint — you cannot hold idle pools open to hundreds of databases — so you provision connections lazily and lean hard on a pooler. In practice this model is best reserved for a smaller number of high-value tenants, often as a premium tier sitting alongside a shared-schema standard tier, rather than the default for everyone.
06Migrations & background jobs
Whichever model you pick, two things will bite teams who only think about the web request: schema migrations and background jobs. Both run outside the request cycle, so the tenant context your middleware set up is gone.
Migrations. In shared schema, migrations are ordinary Django migrations — one run, done. In schema-per-tenant, each migration must run against every schema; django-tenants gives you migrate_schemas for exactly this. In database-per-tenant you loop over tenants and migrate each database. The operational rule is the same in both: treat tenant migration as a monitored, resumable job, run it as part of deploy, and alert if any tenant is left on an old schema version — a half-migrated fleet is the worst place to be.
Background jobs. This is the single most common source of cross-tenant bugs. A Celery worker is a different process; it never saw the request, so you must pass the tenant explicitly and re-establish context inside the task. Never rely on the context variable surviving — it won't.
from celery import shared_task
from tenancy.context import set_current_tenant
from tenancy.models import Tenant
@shared_task
def send_invoice(tenant_id: int, invoice_id: int):
# Re-establish the tenant — the worker has no request context
tenant = Tenant.objects.get(pk=tenant_id)
set_current_tenant(tenant)
try:
from billing.models import Invoice
invoice = Invoice.objects.get(pk=invoice_id) # scoped to tenant
invoice.render_and_email()
finally:
set_current_tenant(None)
# Enqueue with the tenant id, always:
# send_invoice.delay(request.tenant.id, invoice.id)
Wrap set_current_tenant / teardown in a small task base class or decorator so every task is tenant-aware by construction, and have the first task argument always be tenant_id. The goal across all of this is the same: a developer who forgets about tenancy should get a safe default (no data) rather than a leak.
07Performance at scale
Isolation is half the job; the other half is staying fast as tenant count and per-tenant data both grow. The performance failures in multi-tenant systems are specific, and they're easy to design around once you know them:
- Put the tenant column first in composite indexes. In shared schema, almost every query filters by tenant. An index on
(tenant_id, created_at)servesWHERE tenant_id = ? ORDER BY created_atperfectly; an index oncreated_atalone forces the planner to scan across tenants. This one decision is the difference between linear and painful as your row counts climb. - Pool connections. Every model multiplies connections — many tenants, many workers, many web processes. PostgreSQL's per-connection memory makes this the most common scaling wall. Put PgBouncer in front (transaction pooling) so thousands of app-side connections map onto a small, sane set of real ones.
- Namespace every cache key by tenant. A cache key of
"dashboard_stats"will serve tenant A's numbers to tenant B — a data leak through the cache layer. Always key asf"t:{tenant.id}:dashboard_stats". The same applies to rate-limit buckets and any other shared store. - Profile per tenant, not in aggregate. Average latency hides the noisy neighbour. Track p95 per tenant; the tenant holding 80% of the rows, or running giant exports, will show up there long before it shows up in the mean.
class Invoice(TenantModel):
number = models.CharField(max_length=32)
created_at = models.DateTimeField(auto_now_add=True)
total = models.DecimalField(max_digits=12, decimal_places=2)
class Meta:
indexes = [
# tenant_id FIRST — matches the default WHERE clause
models.Index(fields=["tenant", "-created_at"]),
]
# unique per-tenant, not globally
constraints = [
models.UniqueConstraint(fields=["tenant", "number"],
name="uniq_invoice_number_per_tenant"),
]
def dashboard_stats(tenant):
from django.core.cache import cache
key = f"t:{tenant.id}:dashboard_stats" # namespaced per tenant
stats = cache.get(key)
if stats is None:
stats = compute_dashboard_stats(tenant)
cache.set(key, stats, timeout=300)
return stats
None of this is exotic — it's the same query, caching and connection discipline we apply to any Django app, with the tenant dimension threaded through it. Our Django production performance guide covers the non-tenant fundamentals (N+1 queries, select_related, Redis, Celery offloading) that compound with these.
08Choosing a model — and what it costs
Pick the model by your hardest requirement, then default to the simplest option that satisfies it:
- Default to shared schema. Many tenants, fast onboarding, easy cross-tenant analytics, one migration per deploy. Add RLS once data sensitivity warrants it. This carries the large majority of B2B SaaS comfortably.
- Step up to schema-per-tenant when you have a few hundred mid-sized tenants who each value real separation, or when per-tenant migrations and backups would simplify your operations more than they complicate them.
- Reserve database-per-tenant for enterprise tiers with data-residency, per-tenant SLA or independent-restore requirements — often as a premium tier beside a shared-schema base, not the universal default.
A note on build cost, because founders always ask. The isolation model is one of the few early decisions where the cheap option and the right option usually coincide: shared schema is both the least work to build and the easiest to operate for most teams. The expensive scenario is almost always the retrofit — discovering at Series A that an enterprise prospect needs data-per-tenant and you've baked shared-schema assumptions into 200 views. A day of architecture review up front is the cheapest insurance you'll buy.
Architecture review, isolation model, migration & scaling plan — £8k flat, and the deliverable is yours whether or not you build with us.
See Discovery Sprints →09FAQ
filter(tenant=...) on every query.search_path is switched per request based on the domain. A shared-schema approach keeps all tenants in one schema and separates them with a tenant column. Schema-per-tenant gives stronger isolation and per-tenant migrations at the cost of more operational overhead and slower cross-tenant analytics.