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
- DRF Integration
- 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
1. Add to INSTALLED_APPS
INSTALLED_APPS = [
...
"django_tenants_router",
]
2. 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/"],
"COMMON_APPS": ("admin", "auth", "contenttypes", "sessions") # Add the applications which you want migrate as common
}
3. Run root DB migrations
python manage.py migrate # migrates root DB (default) only
4. 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
5. 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 Reference
| Key | Default | Description |
|---|---|---|
ROOT_DB |
"default" |
Django DB alias for the root / router database. |
REDIS_URL |
None |
Redis connection URL. Omit to disable caching. |
CACHE_TTL |
300 |
Cache TTL in seconds. |
CACHE_KEY_PREFIX |
"tenants" |
Redis key namespace. |
TENANT_HEADER |
"HTTP_X_TENANT_ID" |
Django META header name (HTTP_* format). |
TENANT_REQUIRED |
False |
Reject requests with no resolvable tenant. |
TENANT_EXEMPT_PATHS |
["/admin/", "/health/"] |
Paths that bypass tenant resolution. |
ENCRYPTION_DECYPTION_KEY |
Generated Key |
Pass you Fernet Key to Encrypt/Decrypt Tenants DB Passwords |
COMMON_APPS |
("admin", "auth", "contenttypes", "sessions") |
Here you need to define the app names which will use as a common to migrate in both root db and tenant db as well |
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.
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)
...
DRF Integration
from django_tenants_router.drf import TenantModelViewSet, TenantPermission
class OrderViewSet(TenantModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.all()
# All queryset operations are automatically scoped to the tenant DB.
# Response includes _tenant metadata (id, plan).
URL pattern:
router.register(r"tenants/(?P<tenant_id>[^/.]+)/orders", OrderViewSet)
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.
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:
- Step 1
from cryptography.fernet import Fernet print(Fernet.generate_key().decode())
- Step 2
TENANT_ROUTER_CONFIG = { "ENCRYPTION_DECYPTION_KEY": "bkey.....=" }
- Step 1
- Cross-tenant isolation: The router blocks cross-tenant ORM relations at the
allow_relationlevel. - TENANT_REQUIRED: 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.5
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.5.tar.gz.
File metadata
- Download URL: django_tenants_router-1.0.5.tar.gz
- Upload date:
- Size: 37.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
500dfd50e782b54aeb93cca6228eb66783a37cce209a39f8cb10d61bd6741fd4
|
|
| MD5 |
ae73aef2a9eff08571255ef8a64ef24d
|
|
| BLAKE2b-256 |
470fdf1cf37928dba1fc2f4e1464816629111380677817546ad69a6b624a117a
|
File details
Details for the file django_tenants_router-1.0.5-py3-none-any.whl.
File metadata
- Download URL: django_tenants_router-1.0.5-py3-none-any.whl
- Upload date:
- Size: 34.8 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 |
c598213757a048b9586da7995a7e8c801d6217b47e0e44fa8daa225382c6c718
|
|
| MD5 |
8b41049a79f9619699a81a284a73e24d
|
|
| BLAKE2b-256 |
2683a4fee290415955b202e800c071bc75bceea3b722206305e5a70680aa9f38
|