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:

  1. Shared Database, Shared Schema: All tenants share the same database tables. Data is separated by a tenant identifier column (e.g., tenant_id).
  2. Shared Database, Isolated Schema: Tenants share a single PostgreSQL database but maintain dedicated database schemas.
  3. 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:

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 True

The 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_b

Enterprise teams frequently wrap this logic in custom management commands to automate schema updates across the entire tenant fleet safely without cross-tenant schema drift.