Django Multi-Tenant Database Routing Guide
This article explores how Django manages multi-tenant database
routing in enterprise Python environments. It covers the core
multi-tenancy architectural patterns, the mechanics of custom database
routers, tenant context extraction via middleware using
contextvars, and the operational considerations required
for migrations and data isolation at scale.
Multi-Tenancy Architecture Options in Django
Enterprise applications typically choose one of three strategies for multi-tenancy:
- Shared Database, Shared Schema: All tenants share
the same database tables. Data is separated by a tenant identifier
column (e.g.,
tenant_id). - Shared Database, Isolated Schema: Tenants share a single PostgreSQL database but maintain dedicated database schemas.
- Database-per-Tenant: Each tenant operates within a completely separate physical or logical database.
Django natively supports multi-database setups, making the database-per-tenant model a natural fit for applications requiring strict regulatory compliance, physical data isolation, and independent scaling.
Core Component: Custom Database Routers
Django uses database routers to decide which database executes a query. A database router is a Python class that defines up to four methods:
db_for_read(model, **hints): Determines which database should be used for read operations for models of various types.db_for_write(model, **hints): Determines which database should be used for write operations.allow_relation(obj1, obj2, **hints): Determines if a relation between two objects should be allowed. In multi-tenant environments, this method strictly returnsFalseif objects belong to different tenant databases.allow_migrate(db, app_label, model_name=None, **hints): Determines whether a migration operation is allowed to run on the database with the given alias.
class TenantDatabaseRouter:
def db_for_read(self, model, **hints):
return get_current_tenant_db()
def db_for_write(self, model, **hints):
return get_current_tenant_db()
def allow_relation(self, obj1, obj2, **hints):
obj1_db = getattr(obj1._state, 'db', None)
obj2_db = getattr(obj2._state, 'db', None)
if obj1_db and obj2_db:
return obj1_db == obj2_db
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
# Prevent running tenant migrations on the default system database
if db == 'default':
return app_label in ['auth', 'contenttypes', 'sessions']
return TrueThe router is registered in settings.py:
DATABASE_ROUTERS = ['path.to.TenantDatabaseRouter']Context Isolation Using Middleware and ContextVars
Because database routers do not have direct access to the HTTP
request object, the application must store the active
tenant state globally for the duration of the request lifecycle.
Modern enterprise Django applications use Python's
contextvars module instead of threading.local
to maintain thread-safe and asynchronous task-safe execution
contexts.
from contextvars import ContextVar
_current_tenant_db: ContextVar[str] = ContextVar('current_tenant_db', default='default')
def set_current_tenant_db(db_name: str):
_current_tenant_db.set(db_name)
def get_current_tenant_db() -> str:
return _current_tenant_db.get()A custom middleware extracts tenant information—derived from a
subdomain, an HTTP request header (such as X-Tenant-ID), or
a JWT claim—and binds the corresponding database alias to the
context:
from django.utils.deprecation import MiddlewareMixin
class TenantMiddleware(MiddlewareMixin):
def process_request(self, request):
tenant_identifier = request.headers.get('X-Tenant-ID')
db_alias = self.resolve_tenant_to_db(tenant_identifier)
set_current_tenant_db(db_alias)
def process_response(self, request, response):
set_current_tenant_db('default')
return response
def resolve_tenant_to_db(self, identifier):
return identifier if identifier in settings.DATABASES else 'default'Dynamic Database Configuration
In environments with hundreds or thousands of tenants, statically
defining every connection in settings.DATABASES is
impractical. Django allows connections to be dynamically added to
django.db.connections at runtime.
When an unconfigured tenant request arrives, the application queries
a master database for the tenant's connection credentials, dynamically
populates django.db.connections.databases[db_alias], and
routes queries accordingly.
Migrations and Operational Management
Managing database migrations across isolated tenant databases
requires scripted orchestration. The standard
./manage.py migrate command defaults to the
default alias.
In multi-tenant architectures, deployment pipelines loop through all active tenant databases to apply migrations sequentially or concurrently:
python manage.py migrate --database=tenant_a
python manage.py migrate --database=tenant_bEnterprise teams frequently wrap this logic in custom management commands to automate schema updates across the entire tenant fleet safely without cross-tenant schema drift.