Architecture¶
Who is this for: anyone changing the library's core, or debugging why a query went to the wrong database.
TL;DR: a request's host picks a Client row in the shared database; the middleware registers that client's database under the alias client.slug (if this process has not done so already) and stores the client in a ContextVar; the database router sends every query of a non-shared app to that alias. Code outside requests sets the same ContextVar with tenant_context().
Databases¶
There are two kinds of database:
| Database | Alias | Holds |
|---|---|---|
| Shared database | TEMPLATE_ALIAS (default "default") |
The client registry (Client, ClientDomain). With TENANT_APPS_ON_DEFAULT = True (the default), also a copy of every tenant-app table, so superusers can log in on a control host |
| Tenant database, one per client | client.slug |
auth, sessions, contenttypes, admin and every other app not in SHARED_APPS |
The shared database's DATABASES entry is also the template for tenant connections. How a tenant's connection is derived from it is decided by the client's database strategy (Client.db_strategy). The built-in TemplateOverlay copies the template and lays the client's db_params (host, user, password, options...) over it, so the simplest deployment is many databases on one server, reached with one role.
The Model reference shows which table lives where.
Request lifecycle¶
sequenceDiagram
participant B as Browser
participant M as TenantMiddleware
participant R as Resolver / registry
participant D as db.ensure_db_registered
participant V as View + ORM
participant RT as TenantRouter
B->>M: GET https://acme.example.com/notes/
alt path starts with an EXEMPT_PATHS prefix
M->>V: request.tenant = None, no lookup
else
M->>R: RESOLVER(request)
R-->>M: Client (cached by host) or TenantError
M->>D: ensure_db_registered(client)
D-->>M: alias "acme" present in connections
M->>V: request.tenant = client, ContextVar = client
V->>RT: Note.objects.all()
RT-->>V: db_for_read -> "acme"
V-->>M: response
M->>M: reset ContextVar
end
M-->>B: response
Step by step (middleware.py):
- If
request.pathstarts with one ofEXEMPT_PATHS, the middleware setsrequest.tenant = Noneand passes the request on. Queries then go to the shared database. - Otherwise it calls the configured resolver. The default,
resolve_by_host, strips the port, lower-cases the host and returnsNonefor control hosts; for any other host it asks the registry for the matching active client. - A
TenantError(TenantNotFoundorTenantInactive) ends the request: withNOT_FOUND_HANDLERset, its response is returned; otherwise a plain404 Unknown tenant. - For a client,
ensure_db_registered(client)makes sure the alias exists in this process (see below). - The middleware sets
request.tenantand the tenant context, calls the rest of the stack, and resets the context in afinallyblock.
The middleware must sit before SessionMiddleware and AuthenticationMiddleware, so that sessions and users are read from the tenant database.
Under ASGI the middleware runs as a coroutine; the resolver and the registration run in sync_to_async(..., thread_sensitive=True), because both may query the database. The context is a ContextVar, which follows the request across threads, asyncio tasks and asgiref's sync/async bridges.
Database routing¶
TenantRouter (router.py) decides per app label:
| Question | Shared app (SHARED_APPS) |
Any other app |
|---|---|---|
db_for_read / db_for_write |
TEMPLATE_ALIAS |
the active tenant's alias, or TEMPLATE_ALIAS when no tenant is active |
allow_migrate on TEMPLATE_ALIAS |
yes | TENANT_APPS_ON_DEFAULT |
allow_migrate on a tenant alias |
no | yes |
allow_relation |
refused when both objects are saved in different databases; otherwise no opinion | same |
Explicit .using(alias) calls bypass the router, as in plain Django.
Consequence: never add a ForeignKey from a tenant app to Client. The two tables live in different databases, and Django cannot follow relations across databases. Store client.slug and look the client up with registry.get_client(slug).
Runtime database registration¶
Django reads connections.settings (also exposed as connections.databases) every time it opens a connection, and opens connections lazily. Adding an entry to that dict is enough to make a new alias usable. ensure_db_registered(client) in db.py:
- Returns at once when this process already registered the alias with the same config version (
client.updated_at). This is the path almost every request takes; it takes no lock. - Otherwise, under a process-wide lock, it passes a deep copy of the template entry to the client's database strategy,
client.get_db_strategy().connection_settings(template), which returns the tenant's complete settings. WithTemplateOverlay, that is the template with the client'sdb_paramslaid over it (OPTIONSmerged) andNAME = client.db_name. It then setsTEST["NAME"] = None, whatever the strategy returned, so a test run never reuses the template's test database name. See Database strategies. - It stores the entry under
client.slug, records the version, and closes the connection the current thread may hold for that alias, so new credentials apply at once.
Registration is per process. Each worker registers an alias the first time it meets a client; a worker that never served a client holds no entry for it. When a client's row changes (for example a password rotation), updated_at changes, every process sees a new config version the next time it gets the client from the registry, and re-registers.
unregister_db(alias) closes the current thread's connection and removes the alias; the post_delete signal of Client calls it.
Client lookup cache and invalidation¶
registry.py keeps Client objects in the cache named by CACHE_ALIAS, for CACHE_TTL seconds (default 60):
| Key | Value |
|---|---|
django_db_tenants:host:<host> |
the client that owns the domain, or a "miss" marker for unknown hosts (negative caching) |
django_db_tenants:slug:<slug> |
the client with that slug |
get_client_for_host() raises TenantInactive for a client that is not active. get_client() (by slug) does not check the status: tenant_context("acme") and tenant_shell acme work on suspended clients too.
The signal handlers in signals.py delete the affected keys when rows change through the ORM:
| Event | Effect |
|---|---|
Client saved |
slug key and every host key of the client deleted |
Client deleted |
same, then unregister_db(client.slug) in this process |
ClientDomain saved or deleted |
host key of that domain, plus every key of its client, deleted |
How fast other processes see a change depends on the cache backend. With a shared cache (Redis, Memcached), the deletion is visible to every process at once. With a per-process cache such as LocMemCache, other processes keep their copy until it expires, at most CACHE_TTL seconds.
Limits of the invalidation:
QuerySet.update(), raw SQL and changes made outside Django send no signals; the cached entries expire afterCACHE_TTL.- When a domain is renamed, the key of the old host name is not deleted: it keeps resolving to the client until it expires.
Code outside requests¶
Management commands, Celery or RQ tasks, cron jobs and shells never pass through the middleware, so no tenant is active and every query goes to the shared database. They name their tenant explicitly:
| Tool | Use |
|---|---|
tenant_context(client_or_slug) |
Context manager; None activates the shared database |
@with_tenant |
Decorator for task functions that take tenant (slug or Client) as first argument or keyword |
for_each_tenant(func) |
Runs func(client) for every active client (or every client) inside its context |
tenant_shell <slug> |
Django shell with the tenant active |
See Background tasks and Context.
Provisioning¶
provision(client) (provisioning.py) onboards a client in three idempotent steps:
create_database()asks the client's database strategy to create the database.TemplateOverlaydoes it on the server and with the account of the tenant's own connection settings, which needs the right to create databases:- PostgreSQL: connects to the maintenance database (
db_params["MAINTENANCE_DB"], defaultpostgres), checkspg_database, runsCREATE DATABASE. - MySQL: connects without a default database,
CREATE DATABASE IF NOT EXISTS ... CHARACTER SET utf8mb4. - SQLite: nothing; the file appears on first connection.
- Any other vendor:
NotImplementedError.
- PostgreSQL: connects to the maintenance database (
migrate_tenant()registers the alias and runsmigrate --database=<slug>.- The client's status becomes
active, which makes it resolvable by host.
The provision_tenant command and the admin action Provision database both call provision(). The reverse, drop_database(client), closes the current thread's connection to the tenant and asks the strategy to drop the database; the client row is left alone.
Authentication¶
The library does not authenticate users. Tenant users are ordinary Django users stored in the tenant database (auth and sessions are tenant apps), so any Django authentication works per tenant: the demo project uses django.contrib.auth's login views with passwords.
Secrets at rest¶
Client.db_params (an EncryptedJSONField, holding the database password among the strategy parameters) is Fernet-encrypted when written and decrypted when read. The key is ENCRYPTION_KEY, or one derived from SECRET_KEY when it is unset. See Encryption.
Cache isolation¶
Tenants share the cache servers. cache.tenant_key_func, set as a cache's KEY_FUNCTION, puts the active tenant's alias in every key (<prefix>:<version>:@<slug>:<key>), leaves keys written without a tenant in Django's default format, and never namespaces the registry's own django_db_tenants: keys. See Cache isolation.
Known constraints¶
- One connection per tenant per worker. A worker keeps a connection for each tenant it served, subject to
CONN_MAX_AGE. For many tenants, keepCONN_MAX_AGElow or use a pooler such as PgBouncer. - The slug is permanent. It is the database alias and a cache namespace; the admin makes it read-only after creation.
defaultand the template alias are reserved (Client.clean()). - Streaming responses. The context is reset when the view returns. The body of a
StreamingHttpResponseis produced after that, so queries made while streaming run without the tenant (on the shared database) unless the generator activates it withtenant_context(). - No cross-database relations. See Database routing.
- Secrets in the cache. The registry caches whole
Clientobjects, pickled, inCACHE_ALIAS: the cached copy holdsdb_paramsdecrypted, with the database passwords and any other secret the strategy keeps there. Use a cache that only the application can reach, with authentication and TLS where the network is shared.
Next steps¶
- Field-level details: Model reference.
- Every setting mentioned here: Settings.