Django Multi-Tenancy: Shared Database Schema Patterns
Implement multi-tenancy in Django using shared database schemas — tenant models, middleware, filtered QuerySets, and data isolation.
What you'll learn
- ✓The three main multi-tenancy strategies and their tradeoffs
- ✓How to implement shared-database multi-tenancy with a tenant FK
- ✓How middleware resolves the current tenant from the request
- ✓How custom managers enforce tenant isolation at the ORM level
- ✓How to prevent cross-tenant data leaks
Prerequisites
- •Django models, views, and middleware basics
- •Understanding of ForeignKey relationships
- •Custom managers (helpful but not required)
Multi-tenancy lets a single Django application serve multiple customers (tenants) while keeping their data isolated. A project management SaaS, a helpdesk platform, a CMS — all serve many organizations from one codebase and one deployment. The key question is how you isolate tenant data.
Multi-Tenancy Strategies
There are three common approaches:
Separate databases — each tenant gets their own database. Maximum isolation, but operationally expensive (migrations, backups, connection pooling per tenant).
Separate schemas — each tenant gets their own PostgreSQL schema within the same database. Good isolation with lower overhead than separate databases. The django-tenants library implements this well.
Shared database, shared schema — all tenants share the same tables, distinguished by a tenant_id foreign key. Simplest to implement and operate, but requires disciplined query filtering.
This guide focuses on the shared schema approach — the most common pattern for SaaS applications that don’t need schema-level isolation.
Setting Up the Tenant Model
Start with a tenant model that represents each customer:
# tenants/models.py
from django.db import models
class Tenant(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
domain = models.CharField(max_length=253, unique=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
Link users to tenants. A user can belong to one or many tenants:
# tenants/models.py
class TenantMembership(models.Model):
user = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships")
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships")
role = models.CharField(max_length=20, choices=[
("owner", "Owner"),
("admin", "Admin"),
("member", "Member"),
])
class Meta:
unique_together = ("user", "tenant")
Making Models Tenant-Aware
Every model that holds tenant-specific data needs a tenant foreign key:
# core/models.py
from django.db import models
class TenantModel(models.Model):
"""Abstract base class for all tenant-scoped models."""
tenant = models.ForeignKey(
"tenants.Tenant",
on_delete=models.CASCADE,
related_name="%(class)s_set",
)
class Meta:
abstract = True
# projects/models.py
from core.models import TenantModel
from django.db import models
class Project(TenantModel):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# Unique within a tenant, not globally
unique_together = ("tenant", "name")
class Task(TenantModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks")
title = models.CharField(max_length=200)
completed = models.BooleanField(default=False)
Tenant Resolution Middleware
The middleware determines which tenant the current request belongs to. Common strategies: subdomain, URL path, or a header.
Subdomain-Based Resolution
# tenants/middleware.py
from django.http import Http404
from .models import Tenant
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
hostname = request.get_host().split(":")[0] # Strip port
# Extract subdomain: acme.example.com → acme
parts = hostname.split(".")
if len(parts) >= 3:
subdomain = parts[0]
else:
subdomain = None
if subdomain:
try:
request.tenant = Tenant.objects.get(slug=subdomain, is_active=True)
except Tenant.DoesNotExist:
raise Http404("Tenant not found")
else:
request.tenant = None # Public pages, marketing site
response = self.get_response(request)
return response
Header-Based Resolution (API)
For APIs where clients send a tenant identifier in a header:
class TenantHeaderMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tenant_slug = request.headers.get("X-Tenant")
if tenant_slug:
try:
request.tenant = Tenant.objects.get(slug=tenant_slug, is_active=True)
except Tenant.DoesNotExist:
from django.http import JsonResponse
return JsonResponse({"error": "Invalid tenant"}, status=400)
else:
request.tenant = None
return self.get_response(request)
Register the middleware after AuthenticationMiddleware:
# settings.py
MIDDLEWARE = [
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"tenants.middleware.TenantMiddleware",
# ...
]
Thread-Local Tenant Context
Some code (model managers, signals, utility functions) doesn’t have access to the request. Use thread-local storage to make the current tenant globally accessible:
# tenants/context.py
import threading
_thread_locals = threading.local()
def set_current_tenant(tenant):
_thread_locals.tenant = tenant
def get_current_tenant():
return getattr(_thread_locals, "tenant", None)
def clear_current_tenant():
_thread_locals.tenant = None
Update the middleware to set it:
from .context import set_current_tenant, clear_current_tenant
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# ... resolve tenant ...
set_current_tenant(request.tenant)
try:
response = self.get_response(request)
finally:
clear_current_tenant() # Always clean up
return response
Automatic Tenant Filtering with Custom Managers
The most critical piece: every query must be filtered by tenant. A custom manager enforces this automatically:
# core/managers.py
from django.db import models
from tenants.context import get_current_tenant
class TenantQuerySet(models.QuerySet):
def for_tenant(self, tenant):
return self.filter(tenant=tenant)
class TenantManager(models.Manager):
def get_queryset(self):
queryset = TenantQuerySet(self.model, using=self._db)
tenant = get_current_tenant()
if tenant is not None:
queryset = queryset.filter(tenant=tenant)
return queryset
Apply it to the abstract base model:
# core/models.py
from .managers import TenantManager
class TenantModel(models.Model):
tenant = models.ForeignKey(
"tenants.Tenant",
on_delete=models.CASCADE,
related_name="%(class)s_set",
)
objects = TenantManager()
unscoped = models.Manager() # Escape hatch for admin/migrations
class Meta:
abstract = True
Now Project.objects.all() automatically filters by the current tenant. Use Project.unscoped.all() for admin views or management commands that need cross-tenant access.
Automatic Tenant Assignment on Save
Set the tenant automatically when creating objects:
# core/models.py
class TenantModel(models.Model):
tenant = models.ForeignKey("tenants.Tenant", on_delete=models.CASCADE, related_name="%(class)s_set")
objects = TenantManager()
unscoped = models.Manager()
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.tenant_id:
from tenants.context import get_current_tenant
tenant = get_current_tenant()
if tenant:
self.tenant = tenant
super().save(*args, **kwargs)
Preventing Cross-Tenant Data Leaks
Database Constraints
Add unique constraints scoped to tenant to prevent duplicate data across tenants:
class Project(TenantModel):
name = models.CharField(max_length=200)
class Meta:
constraints = [
models.UniqueConstraint(fields=["tenant", "name"], name="unique_project_per_tenant"),
]
Validating Foreign Keys
When a Task references a Project, both must belong to the same tenant:
class Task(TenantModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
def clean(self):
if self.project_id and self.project.tenant_id != self.tenant_id:
raise ValidationError("Task and Project must belong to the same tenant")
Admin Safety
Override the admin queryset to scope by tenant, or use unscoped with caution:
# projects/admin.py
from django.contrib import admin
from .models import Project
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "tenant", "created_at"]
list_filter = ["tenant"]
def get_queryset(self, request):
return Project.unscoped.all() # Admin sees all tenants
Testing Multi-Tenant Code
from django.test import TestCase, RequestFactory
from tenants.models import Tenant
from tenants.context import set_current_tenant, clear_current_tenant
from projects.models import Project
class TenantIsolationTest(TestCase):
def setUp(self):
self.tenant_a = Tenant.objects.create(name="Acme", slug="acme")
self.tenant_b = Tenant.objects.create(name="Globex", slug="globex")
def tearDown(self):
clear_current_tenant()
def test_queries_scoped_to_current_tenant(self):
Project.unscoped.create(name="Alpha", tenant=self.tenant_a)
Project.unscoped.create(name="Beta", tenant=self.tenant_b)
set_current_tenant(self.tenant_a)
self.assertEqual(Project.objects.count(), 1)
self.assertEqual(Project.objects.first().name, "Alpha")
def test_tenant_b_cannot_see_tenant_a_data(self):
Project.unscoped.create(name="Secret", tenant=self.tenant_a)
set_current_tenant(self.tenant_b)
self.assertEqual(Project.objects.count(), 0)
Summary
Shared-schema multi-tenancy in Django uses a tenant FK on every tenant-scoped model, middleware to resolve the tenant from the request, thread-local context for global access, and custom managers to filter queries automatically. Add database-level unique constraints scoped to the tenant, validate cross-model FK consistency, and always test for data isolation between tenants.
Related articles
- Django Django + Celery: Background Tasks and Task Queues
Integrate Celery with Django for background task processing — setup, writing tasks, retries, periodic tasks, and monitoring with Flower.
- Django Django Custom Managers and Chainable QuerySets
Build custom managers and chainable QuerySets in Django to encapsulate query logic, keep views thin, and write expressive ORM code.
- Django Writing Custom Middleware in Django
Build custom Django middleware — request/response processing, exception handling, middleware ordering, and real-world examples.
- Django DRF ViewSets and Routers: Clean API Endpoints Fast
Build REST APIs with Django REST Framework ViewSets and Routers — ModelViewSet, custom actions, filtering, and URL generation.