Skip to main content

Multi-tenant database routing for Django with Redis caching

Project description

django-tenants-router

A production-ready Django library for multi-tenant database routing with a Redis caching layer, automatic tenant resolution middleware, and developer-friendly test utilities.


Table of Contents

  1. Architecture Overview
  2. Installation
  3. Quick Start
  4. Configuration Reference
  5. How It Works
  6. Management Commands
  7. Middleware
  8. Context Managers & Decorators
  9. Redis Caching
  10. Testing
  11. Admin Interface
  12. CI/CD & Scripts
  13. Security Notes

Architecture Overview

                          ┌───────────────────────────────┐
                          │         ROOT DATABASE          │
                          │  (default DB / "router DB")   │
                          │                                │
                          │  • tenants_tenant              │
                          │  • tenants_tenant_db_config    │
                          └──────────────┬────────────────┘
                                         │  reads at startup
                                         ▼
                          ┌───────────────────────────────┐
                          │       TenantRegistry           │
                          │   (in-memory: id → alias)     │
                          └──────────────┬────────────────┘
                                         │
               ┌─────────────────────────┼─────────────────────────┐
               │                         │                         │
               ▼                         ▼                         ▼
        ┌─────────────┐          ┌─────────────┐          ┌─────────────┐
        │  Tenant A   │          │  Tenant B   │          │  Tenant C   │
        │  (acme_db)  │          │ (globex_db) │          │  (init_db)  │
        └─────────────┘          └─────────────┘          └─────────────┘

           Redis cache layer sits in front of registry lookups (optional).
  • Root DB stores only tenant configuration — no business data.
  • Tenant DBs store all application data. They are isolated from each other.
  • Redis caches tenant_id → db_alias lookups for sub-millisecond routing.
  • TenantMiddleware resolves the tenant on every request and sets a thread-local DB alias.
  • TenantDatabaseRouter routes all ORM calls to the correct DB automatically.

Installation

pip install django-tenants-router

# With Redis support (recommended):
pip install "django-tenants-router[redis]"

# With DRF integration:
pip install "django-tenants-router[drf]"

# Everything:
pip install "django-tenants-router[all]"

Quick Start

Add to INSTALLED_APPS

INSTALLED_APPS = [
    ...
    "django_tenants_router",
]

Configure settings

DATABASES = {
    "default": {  # Root DB — stores tenant configs only
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "root_db",
        "USER": "postgres",
        "PASSWORD": "secret",
        "HOST": "localhost",
        "PORT": "5432",
    }
    # Tenant DBs are added dynamically at startup from TenantDatabaseConfig rows.
}

DATABASE_ROUTERS = ["django_tenants_router.router.TenantDatabaseRouter"]

MIDDLEWARE = [
    ...
    "django_tenants_router.middleware.TenantMiddleware",
]

TENANT_ROUTER_CONFIG = {
    "ROOT_DB": "default",
    "REDIS_URL": "redis://localhost:6379/0",
    "CACHE_TTL": 300,                          # seconds
    "CACHE_KEY_PREFIX": "tenants",
    "TENANT_HEADER": "HTTP_X_TENANT_ID",       # HTTP header (Django META format)
    "TENANT_REQUIRED": False,                  # True → return 400/403 if no tenant
    "TENANT_EXEMPT_PATHS": ["/admin/", "/health/"],
    "ROUTER_APPS": ("admin", "auth", "contenttypes", "sessions") # Add the applications which you want migrate in Routing/Handler
}

# "ROUTER_APPS" refers to the set of applications whose models are migrated only in the root (router-layer) database. All other applications will be migrated in the tenant databases.

Run root DB migrations

python manage.py migrate          # migrates root DB (default) only

Create your first tenant

python manage.py create_tenant \
    --name "ACME Corp" \
    --slug acme \
    --db-host localhost \
    --db-name acme_db \
    --db-user acme_user \
    --db-password secret \
    --run-migrations

Make API calls with tenant ID in header

GET /api/orders/
X-Tenant-ID: <tenant-uuid>

All ORM calls in that request now automatically hit the ACME tenant database.

Configuration references


Key Default Description
ROOT_DB "default" Root database alias
REDIS_URL None Redis connection URL
CACHE_TTL 300 Cache duration (seconds)
CACHE_KEY_PREFIX "tenants" Redis key prefix
TENANT_HEADER "HTTP_X_TENANT_ID" Request header
TENANT_REQUIRED True Enforce tenant presence
TENANT_EXEMPT_PATHS ["/admin/", "/health/"] Bypass paths
ENCRYPTION_DECYPTION_KEY Strict manually-generated Key for encrypting DB passwords
COMMON_APPS ("admin", "auth", "contenttypes", "sessions") Shared apps

How It Works

Startup

  1. DjangoTenantsRouterConfig.ready() fires.
  2. TenantRegistry.load_from_db() reads all active Tenant + TenantDatabaseConfig rows from the root DB.
  3. Each tenant's DB config is injected into settings.DATABASES and Django's connection handler.
  4. Skip tenants whose database configurations are incorrect (ensure after hitting ensure connection).

Per-request flow

Request arrives
     │
     ▼
TenantMiddleware
     │  resolves tenant_id from: header
     │
     ├─ Redis hit?  →  db_alias  ─────────────────────────────────┐
     ├─ Registry hit?  →  db_alias + write to Redis  ─────────────┤
     └─ DB fallback  →  db_alias + register + cache  ─────────────┤
                                                                   │
                                                        set_tenant_db(db_alias)
                                                                   │
                                                               view runs
                                                                   │
                                                     ORM → TenantDatabaseRouter
                                                     routes to correct tenant DB
                                                                   │
                                                        response returned
                                                                   │
                                                         clear_tenant_db()

Management Commands

migrate (standard)

Migrates only the root DB (due to allow_migrate rules in the router):

python manage.py migrate

migrate_tenant

Migrates a single tenant database:

python manage.py migrate_tenant --tenant-db acme
python manage.py migrate_tenant --tenant-db acme --app orders
python manage.py migrate_tenant --tenant-db acme --fake-initial

migrate_all_tenants

Migrates every registered tenant database (skips root DB):

python manage.py migrate_all_tenants
python manage.py migrate_all_tenants --parallel --workers 8
python manage.py migrate_all_tenants --exclude acme staging --app orders

create_tenant

Interactive or flag-driven tenant creation:

python manage.py create_tenant
python manage.py create_tenant \
    --name "Globex Corp" --slug globex \
    --db-host db.globex.internal --db-name globex \
    --db-user globex_user --db-password s3cr3t \
    --run-migrations

tenant_status

List all registered tenants and optionally verify connectivity:

python manage.py tenant_status
python manage.py tenant_status --ping-dbs --check-cache

Middleware

Add to MIDDLEWARE:

# Sync (WSGI)
"django_tenants_router.middleware.TenantMiddleware"

# Async (ASGI / Channels)
"django_tenants_router.middleware.AsyncTenantMiddleware"

After the middleware runs, request.tenant_id and request.tenant_db are available in your views.


Context Managers & Decorators

Manual context switching

from django_tenants_router.router import tenant_db_context, tenant_context_by_id

# By alias
with tenant_db_context("tenant_acme"):
    orders = Order.objects.all()

# By tenant UUID
with tenant_context_by_id(tenant_id):
    orders = Order.objects.all()

View decorators

from django_tenants_router.decorators import with_tenant, with_tenant_slug

# Routes by URL kwarg
@with_tenant("tenant_id")
def order_list(request, tenant_id):
    return JsonResponse({"orders": list(Order.objects.values())})

# Routes by slug
@with_tenant_slug("tenant_slug")
def dashboard(request, tenant_slug):
    ...

# Run a function for every tenant
from django_tenants_router.decorators import for_each_tenant

@for_each_tenant()
def send_daily_report(db_alias):
    users = User.objects.using(db_alias).filter(active=True)
    ...

Redis Caching

from django_tenants_router.cache import (
    cache_tenant_db,
    get_cached_tenant_db,
    invalidate_tenant,
    cache_tenant_metadata,
    cache_health_check,
    flush_all_tenant_cache,
)

# Warm the cache manually
cache_tenant_db("uuid-1234", "tenant_acme")

# Lookup
alias = get_cached_tenant_db("uuid-1234")

# Invalidate after config change
invalidate_tenant("uuid-1234")

# Health check
print(cache_health_check())
# {'status': 'ok', 'redis_version': '7.0.0', 'uptime_seconds': 86400}

If Redis is unavailable, all cache operations silently no-op. The library always falls back to the in-memory registry or root DB.


Testing

TenantTestCase

from django_tenants_router.test_utils import TenantTestCase

class OrderTest(TenantTestCase):
    tenant_db_alias = "tenant_acme"

    def test_create_order(self):
        with self.tenant_context():
            order = Order.objects.create(amount=100)
            self.assertEqual(Order.objects.count(), 1)

    def test_queryset_on_correct_db(self):
        qs = Order.objects.using(self.tenant_db_alias).all()
        self.assertQueryRunsOnTenantDB(qs)

MockTenantMixin

Patch the registry so tests never need real DB rows:

from django_tenants_router.test_utils import MockTenantMixin

class MyTest(MockTenantMixin, TestCase):
    mock_tenants = {
        "uuid-111": "tenant_alpha",
        "uuid-222": "tenant_beta",
    }

    def test_something(self):
        from django_tenants_router.registry import TenantRegistry
        alias = TenantRegistry.get_db_for_tenant_id("uuid-111")
        self.assertEqual(alias, "tenant_alpha")

override_tenant_db

from django_tenants_router.test_utils import override_tenant_db

with override_tenant_db("tenant_acme"):
    # ORM calls go to tenant_acme
    ...

Running the test suite

pip install -e ".[dev]"
pytest
pytest --cov=django_tenants_router --cov-report=term-missing

Admin Interface

The Django admin includes:

  • Tenant list with live DB connectivity badge.
  • Inline DB config editor on the Tenant change page.
  • Bulk actions: activate, deactivate, flush Redis cache. Connection management: On each update to any tenant database configuration, the system will refresh the database connection from InMemory, Redis, and the Django Connection Manager. It will also refresh the Admin UI.

CI/CD & Scripts

# In your Dockerfile or entrypoint:
python manage.py migrate                        # root DB
python scripts/migrate_all_tenants.py --parallel --workers 4

Or via the management command:

python manage.py migrate_all_tenants --parallel --workers 4

Security Notes

  • Passwords in DB: Without encryption/decryption key it won't work
    • Step 1
      from cryptography.fernet import Fernet
      print(Fernet.generate_key().decode())
      
    • Step 2
      TENANT_ROUTER_CONFIG = {
          "ENCRYPTION_DECYPTION_KEY": "bkey.....="
      }
      
  • TENANT_REQUIRED: Strictly Set to True in all environment APIs so unauthenticated callers cannot accidentally hit the default DB if set to False then then that will not work.

License

MIT

Stable Version

django_tenants_router-1.0.7

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_tenants_router-1.0.7.tar.gz (40.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_tenants_router-1.0.7-py3-none-any.whl (39.3 kB view details)

Uploaded Python 3

File details

Details for the file django_tenants_router-1.0.7.tar.gz.

File metadata

  • Download URL: django_tenants_router-1.0.7.tar.gz
  • Upload date:
  • Size: 40.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.5

File hashes

Hashes for django_tenants_router-1.0.7.tar.gz
Algorithm Hash digest
SHA256 3e3ff459cb3760918a5d21217cb6155e985668154538193a7ca35af53ab87d85
MD5 e14d79866aca91d9ae2956872ef8b6bc
BLAKE2b-256 434f0ac5e12d0c804c41db32ccf39a2352bc85f3269457302f2fe0b216e03eb9

See more details on using hashes here.

File details

Details for the file django_tenants_router-1.0.7-py3-none-any.whl.

File metadata

File hashes

Hashes for django_tenants_router-1.0.7-py3-none-any.whl
Algorithm Hash digest
SHA256 66427738bfe7785d0b3762773468f55f6810807ab1e3b413ee732706d96c4a57
MD5 dfb083b96a683e08e8c1b76bcdaf9884
BLAKE2b-256 67a3fb81ed92b61ae7ccc78216ed4aa340375ebebd6be18ee0f1a124f8df6e52

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page