Multitenancy in Django: keeping clients apart in one application
Created: September 2023 Updated: Sept. 29, 2026
recruitment task
Python, Django, django-tenants, PostgreSQL
One application, many clients, and none of them may see another's data.
Each client must see only their own data, full separation. One query without a client filter is enough for one company's data to land on another's screen.
The hard part is that the separation has to hold everywhere, not just in views: the admin panel, background tasks, reports, the cache, files uploaded by users. If it relies on everyone remembering filter(client=…), someone will eventually forget.
Three ways
A separate database per client gives the strongest isolation and makes it easy to back up or remove one client, except every database means separate connections, migrations and costs, so it makes sense with a few large clients. Shared tables with a client column are the cheapest and scale to thousands of clients, but isolation depends on the code, so the filter has to be enforced in one place, with a model manager, the django-multitenant library or Row Level Security in PostgreSQL. In between there's a separate PostgreSQL schema per client: one database, each client has their own schema with the same tables, and on every request the application switches search_path.
I went with schemas
A separate schema per client with the django-tenants library. The client is recognised by domain, and the rest of the application writes ordinary queries without a client filter, so there's nothing to forget. The downside is that migrations run through every schema, which gets slow with a very large number of clients.
Configuration is mostly about splitting apps into shared ones and those every client has in their own schema:
DATABASES = { "default": {"ENGINE": "django_tenants.postgresql_backend", "NAME": "app"}, } DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) MIDDLEWARE = [ "django_tenants.middleware.main.TenantMainMiddleware", "django.middleware.security.SecurityMiddleware", # ... ] SHARED_APPS = ["django_tenants", "customers", "django.contrib.contenttypes", "django.contrib.auth"] TENANT_APPS = ["orders", "invoices"] INSTALLED_APPS = SHARED_APPS + [app for app in TENANT_APPS if app not in SHARED_APPS] TENANT_MODEL = "customers.Client" TENANT_DOMAIN_MODEL = "customers.Domain"
The client and its domain are ordinary models:
from django.db import models from django_tenants.models import DomainMixin, TenantMixin class Client(TenantMixin): name = models.CharField(max_length=100) auto_create_schema = True class Domain(DomainMixin): pass
Background tasks are the easiest to overlook, because there's no request telling you which client it's about, so you have to set the schema yourself:
from django_tenants.utils import tenant_context from customers.models import Client def send_monthly_invoices(): for client in Client.objects.exclude(schema_name="public"): with tenant_context(client): ... # ordinary queries here, already in the client's schema
Migrations are run with migrate_schemas, separately for the shared part (--shared) and for the client schemas.
Machine-translated from Polish (original).