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
- Architecture Overview
- Installation
- Quick Start
- Configuration Reference
- How It Works
- Management Commands
- Middleware
- Context Managers & Decorators
- Redis Caching
- Testing
- Admin Interface
- CI/CD & Scripts
- 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_aliaslookups 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
DjangoTenantsRouterConfig.ready()fires.TenantRegistry.load_from_db()reads all activeTenant+TenantDatabaseConfigrows from the root DB.- Each tenant's DB config is injected into
settings.DATABASESand Django's connection handler. - 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.....=" }
- Step 1
- TENANT_REQUIRED: Strictly Set to
Truein all environment APIs so unauthenticated callers cannot accidentally hit the default DB if set toFalsethen 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e3ff459cb3760918a5d21217cb6155e985668154538193a7ca35af53ab87d85
|
|
| MD5 |
e14d79866aca91d9ae2956872ef8b6bc
|
|
| BLAKE2b-256 |
434f0ac5e12d0c804c41db32ccf39a2352bc85f3269457302f2fe0b216e03eb9
|
File details
Details for the file django_tenants_router-1.0.7-py3-none-any.whl.
File metadata
- Download URL: django_tenants_router-1.0.7-py3-none-any.whl
- Upload date:
- Size: 39.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
66427738bfe7785d0b3762773468f55f6810807ab1e3b413ee732706d96c4a57
|
|
| MD5 |
dfb083b96a683e08e8c1b76bcdaf9884
|
|
| BLAKE2b-256 |
67a3fb81ed92b61ae7ccc78216ed4aa340375ebebd6be18ee0f1a124f8df6e52
|