Design overview¶
Who is this for: anyone meeting the library for the first time who wants the idea before the details.
TL;DR: every tenant has its own physical database, and application code stays plain Django. A tenant context holds the current tenant; a database router sends every query to that tenant's alias; the alias is added to django.db.connections the first time a process needs it, with connection settings built by the tenant's database strategy.
The five pieces¶
flowchart LR
REQ[Request] --> MW[TenantMiddleware]
MW -- host / header --> REG[Registry<br/>Client, ClientDomain<br/>shared database + cache]
MW -- ensure_db_registered --> DB[db.py<br/>connections.settings]
DB -- connection_settings --> ST[Database strategy]
MW -- set --> CTX[(Tenant context<br/>ContextVar)]
ORM[Views, ORM] --> RT[TenantRouter]
RT -- reads --> CTX
RT -- alias --> TDB[(Tenant database)]
1. The registry of tenants lives in the shared database¶
models.py, registry.py.
- A
Clientrow describes one tenant: itsslug, which is also its database alias; itsdb_name; itsdb_strategy, the class that builds its connection settings; anddb_params, that class's encrypted parameters. - A
ClientDomainrow maps a host name to a client. - These tables exist only in the shared database, the template alias (
DB_TENANTS["TEMPLATE_ALIAS"], default"default"). - Lookups go through the cache (
registry.get_client_for_host()andget_client()), including negative results ("no such host"), so requests do not hit the shared database every time.
2. The tenant context is a ContextVar¶
context.py.
- It holds the active
Client, orNonefor the shared database. - It is a
ContextVarrather than a thread-local, so it stays correct under threads, asyncio and asgiref'ssync_to_async. - Everything else reads it through
get_current_tenant()andget_current_tenant_alias(). tenant_context("acme")sets it for a block of code.
3. The middleware decides the tenant for each request¶
middleware.py, resolvers.py.
TenantMiddlewarecalls the configured resolver:resolve_by_host(the default) mapsacme.example.comto itsClientDomain; control hosts getNone;resolve_by_headerreads the slug fromX-Tenantinstead.
- It then registers the tenant's database, sets the context for the request, and resets it afterwards.
- An unknown host gets a 404, or the response of
NOT_FOUND_HANDLER. Exempt paths such as/healthzskip all of this. - It must sit before the session and auth middleware: sessions and users are tenant data, so they are looked up only once the context points at the tenant.
4. Databases are registered at runtime¶
db.py.
- Django reads
connections.settingseach time it opens a connection, and opens connections lazily. Adding a key to that dict is enough to create a usable alias: no restart, nothing listed inDATABASES. ensure_db_registered(client)takes a deep copy of the template alias's settings, asks the strategy for the tenant's settings (client.get_db_strategy().connection_settings(template)), and stores the result underclient.alias.- Registration happens once per process per tenant, under a lock.
Client.config_version()comes fromupdated_at. When a client row changes, for example after a password rotation, each process sees a new version and re-registers. It also closes the connection the current thread holds, so the new settings take effect at once.
5. The router enforces where data goes¶
router.py. TenantRouter is the only place that decides where queries go:
| Model's app | db_for_read / db_for_write |
|---|---|
In SHARED_APPS (default: only django_db_tenants) |
the template alias, always |
| Anything else (auth, sessions, contenttypes, your apps) | the current tenant's alias, or the template alias when there is no tenant |
allow_relationrefuses relations between objects saved in different databases.allow_migratekeeps shared tables out of tenant databases. Tenant tables also go into the shared database whenTENANT_APPS_ON_DEFAULTis on, so superusers can log in on a control host.
How a request flows¶
GET https://acme.example.com/notes/
│
├─ TenantMiddleware
│ resolve_by_host → registry (cache, else shared DB) → Client "acme"
│ ensure_db_registered(acme) → strategy builds settings → connections.settings["acme"] = {...}
│ context = acme
│
├─ SessionMiddleware / AuthenticationMiddleware ─┐
├─ view: Note.objects.all() ─────────────────────┤→ TenantRouter → "acme" → acme's database
│ ┘
└─ finally: context reset
Architecture has the full sequence, including the exempt-path and error branches and the ASGI path.
The strategy decides how a database is reached¶
db.py and provisioning.py do not know how a tenant's database is reached. They call the strategy through the DbConnectionFactory interface: connection_settings(), create_database(), drop_database() and validate().
The built-in TemplateOverlay lays db_params (HOST, USER, PASSWORD, OPTIONS...) over the template connection and uses client.db_name as NAME. Tenants can therefore live on the same server as the shared database, on other servers, or on another engine. A custom strategy can get credentials elsewhere: the test suite's EnvironmentPassword reads the password from an environment variable. See Database strategies.
The supporting pieces¶
- Code outside requests.
tenant_context()and@with_tenantfor tasks,for_each_tenant()to loop over tenants, and thetenant_shellandmigrate_tenantscommands. They all set the same context, so the router behaves identically. See Background tasks. - Cache isolation.
tenant_key_funcadds@<alias>to cache keys while a tenant is active, so two tenants caching the same key do not read each other's data. The registry's own keys are always shared. See Cache isolation. - Staying consistent. Saving a
ClientorClientDomainclears its cached lookups (signals.py), and deleting a client unregisters its alias. Other processes pick up changes when their cache entry expires, or through the config version. See Client lookup cache and invalidation. - Onboarding.
provision()creates the database through the strategy, runs migrations on the new alias and marks the client active. See Provisioning.
What the design accepts¶
- Connections grow with tenants. A process holds a connection for every tenant it has served, so the number of registered aliases grows with the tenants it meets, and connection limits on the database server need planning.
- No cross-database queries. Anything with a foreign key to
auth.Userorcontenttypesmust be a tenant app, which is whySHARED_APPSis deliberately tiny. - Isolation depends on the context being set. Code that runs with no tenant falls back to the shared database rather than failing. That is why the middleware runs early, and why background code must use
tenant_context()or@with_tenant.
Known constraints lists the remaining limits.
Next steps¶
Follow a request, a task and a command through the code in Architecture.