Multi-Tenancy Multiple Database with Custom routers and Schema based Tenancy in Django
Multi-Database Based Tenancy in Django
In modern SaaS applications, multi-tenancy is a common architectural pattern that allows a single application instance to serve multiple clients (tenants) while keeping their data isolated. One robust way to implement this in Django is database-per-tenant or multi-database tenancy, where each tenant has its own database.
Why Multi-Database Tenancy?
- Data isolation: Each tenant’s data is physically separated, reducing risk of accidental leaks.
- Scalability: Tenant databases can be moved to separate servers or shards independently.
- Custom backups: Each tenant can have its own backup and restore plan.
- Security & compliance: Some industries require strict data separation.
How It Works in Django
Django supports multiple databases natively through DATABASES settings and database routers. Here's the general approach:
1. Public vs Tenant Databases
- Public Database: Contains shared data like user accounts, authentication, and metadata for tenants (like
Clienttable). - Tenant Databases: Each tenant gets its own database storing tenant-specific models (like
Orders,House, etc.).
Shared Apps vs Tenant Apps
When building a multi-tenant Django application with database-per-tenant, it’s important to separate apps based on which database they belong to.
- SHARED_APPS: These apps live in the public database (
default). - They include Django’s core apps (auth, admin, sessions) and a shared app like
customersthat stores tenant metadata. - This ensures that critical data like tenant records, authentication, and user management are common across all tenants.
- TENANT_APPS: These apps are specific to each tenant database.
- For example,
housecontains models that are isolated per tenant. - Each tenant gets its own schema, so data never mixes between tenants.
#settings.py
SHARED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"customers", # tenant management shared app
]
TENANT_APPS = (
# your tenant-specific apps
'house',
)
INSTALLED_APPS = SHARED_APPS + [app for app in TENANT_APPS if app not in SHARED_APPS]Disabling Automatic Migrations for Tenant Apps
- Django normally checks for unapplied migrations for all apps when you run
runserver. - Since tenant databases are created dynamically, we handle their migrations manually.
- Setting
MIGRATION_MODULEStoNonetells Django not to track migrations for tenant apps, so you won’t see warnings like:
You have 1 unapplied migration(s). Your project may not work properly...#settings.py
MIGRATION_MODULES = {app: None for app in TENANT_APPS}Database Configuration
defaultis the public database, containing shared data.- Tenant databases are added dynamically at runtime or via a management command when new tenants are created.
#settings.py
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "public_custom_router_based_db", # shared DB for tenant metadata
"USER": "postgres",
"PASSWORD": "postgres",
"HOST": "localhost",
"PORT": "5432",
}
}Database Router
Get Muhammadahmadazhar’s stories in your inbox
Join Medium for free to get updates from this writer.
The router decides which database each query or migration should go to.
For shared apps (SHARED_APPS), queries always go to default.
For tenant apps (TENANT_APPS), queries go to the current tenant’s database determined by the middleware or context.
#settings.py
DATABASE_ROUTERS = ["customers.routers.TenantRouter"]#customers.routers.py
from .tenant_context import get_current_tenant
from django.conf import settings
class TenantRouter:
def db_for_read(self, model, **hints):
tenant = get_current_tenant()
if tenant and model._meta.app_label != "customers":
return tenant.db_name
return "default"
def db_for_write(self, model, **hints):
tenant = get_current_tenant()
if tenant and model._meta.app_label != "customers":
return tenant.db_name
return "default"
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
# Public DB always has "customers" and Django core apps
if db == "default":
# Only core apps + public apps go here
return app_label in settings.SHARED_APPS
# Tenant DBs only get tenant apps
# Add other tenant apps here
if db != "default":
return app_label in settings.TENANT_APPS
return FalseClient Model
The Client model represents a tenant in a multi-database setup. Each record stores the tenant’s metadata, including its unique database name, credentials, and connection details. This allows the application to dynamically route requests and database operations to the correct tenant database. Essentially, it serves as the central registry of all tenants, enabling multi-tenancy while keeping each tenant’s data isolated.
#customers.models.py
from django.db import models
class Client(models.Model):
name = models.CharField(max_length=100)
db_name = models.CharField(max_length=100, unique=True) # each tenant DB
db_user = models.CharField(max_length=100, default="postgres")
db_password = models.CharField(max_length=100, default="postgres")
db_host = models.CharField(max_length=50, default="localhost")
db_port = models.IntegerField(default=5432)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.nameContext for Current Tenant
ContextVaris a Python feature introduced in Python 3.7.- It allows storing context-local data, which is safe across threads, async tasks, and requests.
- Here,
_current_tenantstores the current tenant object for each request. - Using
ContextVarinstead of thread-local storage (threading.local) is better for async-safe code, which Django supports via ASGI.
#customers.tenant_context.py
from contextvars import ContextVar
_current_tenant = ContextVar("current_tenant", default=None)
def set_current_tenant(tenant):
_current_tenant.set(tenant)
def get_current_tenant():
return _current_tenant.get()
def clear_current_tenant():
_current_tenant.set(None)Middleware
Middleware in Django acts as a layer that processes requests and responses globally for all views. In a multi-database, tenant-based setup, the TenantMiddleware identifies the current tenant based on the request’s subdomain and stores it in a context-safe variable, ensuring that subsequent database operations are routed correctly. Meanwhile, the TenantDBLoaderMiddleware dynamically registers tenant databases in Django’s connection pool at runtime, allowing the application to recognize new tenants without restarting the server. Together, these middlewares enable per-tenant database isolation and seamless multi-tenancy, ensuring that each request automatically interacts with the correct tenant database.
#customers.middleware.py
import copy
from django.conf import settings
from django.db import connections
from .models import Client
from .tenant_context import set_current_tenant, clear_current_tenant
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
#It extracts the host (or subdomain) from the request, e.g., tenant1.localhost
host = request.get_host().split(":")[0]
if host in ("127.0.0.1", "localhost"):
set_current_tenant(None)
else:
subdomain = host.split(".")[0]
try:
tenant = Client.objects.get(db_name=subdomain)
except Client.DoesNotExist:
tenant = None
set_current_tenant(tenant)
try:
return self.get_response(request)
finally:
clear_current_tenant() # ✅ NO MEMORY LEAK
class TenantDBLoaderMiddleware:
"""
Add tenant DB aliases to `connections.databases` dynamically at runtime.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
clients = Client.objects.values_list("db_name", flat=True)
for client in clients:
if client in connections.databases:
print('#'*20)
print("tenant database already exists: skipped.")
continue
# Copy the *full* default config so keys like ATOMIC_REQUESTS exist
default_cfg = copy.deepcopy(settings.DATABASES.get("default", {}))
if not default_cfg:
# No default DB config available — can't register tenant
raise RuntimeError("No default DB configuration available to copy from.")
# Optionally override credentials from settings or per-tenant later
tenant_cfg = default_cfg
tenant_cfg["NAME"] = client
connections.databases[client] = tenant_cfg
return self.get_response(request)python manage.py makemigrations
python manage.py migrate customersThis custom Django management command automates the creation of a new tenant in a multi-database setup. When executed, it first creates a new Client record in the shared public database, storing metadata about the tenant, including its database name. Then, it creates a dedicated PostgreSQL database for the tenant and dynamically registers this database in Django’s settings, so it can be used immediately without restarting the server. Finally, the command applies migrations for all tenant-specific apps directly into the new database, ensuring the tenant is fully set up and ready to use. This process enables instant multi-tenancy, where each tenant has isolated data while sharing the same application code.
#customers.management.commands.create_tenant.py
import copy
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.conf import settings
from customers.models import Client
import psycopg2
import subprocess
class Command(BaseCommand):
help = "Create a new tenant with its own database"
def add_arguments(self, parser):
parser.add_argument("name", type=str, help="Tenant name")
parser.add_argument("subdomain", type=str, help="Tenant subdomain")
def handle(self, *args, **options):
name = options["name"]
subdomain = options["subdomain"]
db_name = f"{subdomain}"
# 1. Create Client entry
tenant = Client.objects.create(name=name, db_name=db_name)
self.stdout.write(self.style.SUCCESS(f"Tenant {name} created in public DB"))
# 2. Create the tenant database
conn = psycopg2.connect(
dbname="postgres",
user=settings.DATABASES["default"]["USER"],
password=settings.DATABASES["default"]["PASSWORD"],
host=settings.DATABASES["default"]["HOST"],
port=settings.DATABASES["default"]["PORT"],
)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute(f"CREATE DATABASE {db_name}")
cursor.close()
conn.close()
self.stdout.write(self.style.SUCCESS(f"Database {db_name} created"))
# ✅ 3. Dynamically register DB in Django
settings.DATABASES[db_name] = copy.deepcopy(settings.DATABASES["default"])
settings.DATABASES[db_name]["NAME"] = db_name
# ✅ 4. Run migrations INSIDE same process
for app in settings.TENANT_APPS:
call_command("migrate", app_label=app, database=db_name, verbosity=1)
self.stdout.write(self.style.SUCCESS(f"Migrations applied to {db_name}"))To run this command, you use Django’s manage.py script with two arguments: the tenant’s name and the subdomain (or database name). For example:
python manage.py create_tenant "Tenant 1" tenant1Here, "Tenant 1" is the human-readable tenant name stored in the public database, and tenant1 is the subdomain that also becomes the tenant’s database name.
To test this locally using subdomains, you need to map the subdomain to localhost in your Linux /etc/hosts file. Open the file with superuser privileges:
sudo nano /etc/hostsThen add a line like this:
127.0.0.1 tenant1.localhostInsert some data in tenant1 db members modle then access each tenant data by visiitng this url:
http://tenant1.localhost:8000/house/members/



