Building Multi-Tenant Apps with Django
Building SaaS means serving multiple customers from one application. Each customer (tenant) expects their data to be isolated and secure. Here’s how to implement multi-tenancy in Django.
Multi-Tenancy Approaches
Option 1: Shared Database, Shared Schema
All tenants in the same tables:
class Article(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
content = models.TextField()
Every query filters by tenant:
Article.objects.filter(tenant=request.tenant)
Pros: Simple, efficient Cons: Requires discipline, migration complexity
Option 2: Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema:
Database: myapp
├── public (shared tables)
├── tenant_acme (Acme's tables)
├── tenant_globex (Globex's tables)
└── tenant_initech (Initech's tables)
Pros: Strong isolation, easy tenant removal Cons: Migration overhead, connection management
Option 3: Separate Databases
Each tenant gets their own database:
├── myapp_acme (Acme's database)
├── myapp_globex (Globex's database)
└── myapp_initech (Initech's database)
Pros: Complete isolation, independent scaling Cons: Complex routing, high resource usage
Approach 1: Tenant Column (Recommended Start)
Base Setup
# models.py
class Tenant(models.Model):
name = models.CharField(max_length=100)
subdomain = models.CharField(max_length=50, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class TenantMixin(models.Model):
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='%(class)s_set'
)
class Meta:
abstract = True
class Article(TenantMixin):
title = models.CharField(max_length=200)
content = models.TextField()
class Meta:
indexes = [
models.Index(fields=['tenant', 'created_at']),
]
Tenant-Aware Manager
class TenantManager(models.Manager):
def get_queryset(self):
# Get tenant from thread-local storage
from .middleware import get_current_tenant
tenant = get_current_tenant()
if tenant:
return super().get_queryset().filter(tenant=tenant)
return super().get_queryset().none()
class Article(TenantMixin):
title = models.CharField(max_length=200)
content = models.TextField()
objects = TenantManager()
all_objects = models.Manager() # Unfiltered access when needed
Middleware
# middleware.py
import threading
_thread_locals = threading.local()
def get_current_tenant():
return getattr(_thread_locals, 'tenant', None)
def set_current_tenant(tenant):
_thread_locals.tenant = tenant
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Get tenant from subdomain
host = request.get_host().split(':')[0]
subdomain = host.split('.')[0]
try:
tenant = Tenant.objects.get(subdomain=subdomain)
request.tenant = tenant
set_current_tenant(tenant)
except Tenant.DoesNotExist:
request.tenant = None
set_current_tenant(None)
response = self.get_response(request)
# Clean up
set_current_tenant(None)
return response
Auto-Setting Tenant
# views.py
class ArticleCreateView(CreateView):
model = Article
fields = ['title', 'content']
def form_valid(self, form):
form.instance.tenant = self.request.tenant
return super().form_valid(form)
Or using signals:
# signals.py
from django.db.models.signals import pre_save
from django.dispatch import receiver
from .middleware import get_current_tenant
@receiver(pre_save)
def set_tenant(sender, instance, **kwargs):
if hasattr(instance, 'tenant_id') and not instance.tenant_id:
tenant = get_current_tenant()
if tenant:
instance.tenant = tenant
Approach 2: Schema-Based (django-tenants)
Installation
pip install django-tenants
Configuration
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django_tenants.postgresql_backend',
'NAME': 'myapp',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'localhost',
}
}
DATABASE_ROUTERS = ('django_tenants.routers.TenantSyncRouter',)
MIDDLEWARE = [
'django_tenants.middleware.main.TenantMainMiddleware',
# ... other middleware
]
SHARED_APPS = [
'django_tenants',
'tenants', # Your tenant model app
'django.contrib.contenttypes',
'django.contrib.auth',
# ...
]
TENANT_APPS = [
'django.contrib.contenttypes',
'articles',
'users',
# Apps with tenant-specific data
]
INSTALLED_APPS = list(SHARED_APPS) + [
app for app in TENANT_APPS if app not in SHARED_APPS
]
TENANT_MODEL = "tenants.Tenant"
TENANT_DOMAIN_MODEL = "tenants.Domain"
Tenant Model
# tenants/models.py
from django_tenants.models import TenantMixin, DomainMixin
class Tenant(TenantMixin):
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
auto_create_schema = True
class Domain(DomainMixin):
pass
Creating Tenants
# Create a new tenant
tenant = Tenant(schema_name='acme', name='Acme Corporation')
tenant.save()
# Create domain for the tenant
domain = Domain(domain='acme.myapp.com', tenant=tenant, is_primary=True)
domain.save()
Accessing Tenant Context
from django_tenants.utils import tenant_context
# Temporarily switch to another tenant
with tenant_context(other_tenant):
articles = Article.objects.all() # Gets other tenant's articles
URL-Based Tenanting
Alternative to subdomains:
# urls.py
urlpatterns = [
path('<str:tenant_slug>/', include([
path('articles/', views.article_list),
path('articles/<int:pk>/', views.article_detail),
])),
]
# middleware.py
class TenantFromURLMiddleware:
def __call__(self, request):
# Extract tenant from URL path
path_parts = request.path.split('/')
if len(path_parts) > 1:
tenant_slug = path_parts[1]
try:
request.tenant = Tenant.objects.get(slug=tenant_slug)
except Tenant.DoesNotExist:
raise Http404("Tenant not found")
return self.get_response(request)
Security Considerations
Query Isolation
Always verify queries are tenant-scoped:
# Anti-pattern: Bypassing tenant filter
article = Article.all_objects.get(pk=pk) # DANGEROUS
# Correct: Always filter by tenant
article = Article.objects.get(pk=pk) # Uses TenantManager
Row-Level Security (PostgreSQL)
Add database-level protection:
-- Enable row-level security
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- Policy for tenant isolation
CREATE POLICY tenant_isolation ON articles
USING (tenant_id = current_setting('app.current_tenant')::int);
Audit Logging
class AuditMixin(models.Model):
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
Performance
Indexes
class Article(TenantMixin):
class Meta:
indexes = [
models.Index(fields=['tenant', 'created_at']),
models.Index(fields=['tenant', 'status']),
]
Caching by Tenant
from django.core.cache import cache
def get_tenant_cache_key(tenant, key):
return f"tenant:{tenant.id}:{key}"
def get_cached_articles(tenant):
cache_key = get_tenant_cache_key(tenant, 'articles')
articles = cache.get(cache_key)
if articles is None:
articles = list(Article.objects.filter(tenant=tenant))
cache.set(cache_key, articles, 3600)
return articles
Final Thoughts
Start with the tenant column approach—it’s simpler and performs well for most use cases. Move to schema-based isolation when you need:
- Stronger data isolation
- Per-tenant customizations
- Independent backups
Multi-tenancy is an architectural decision. Plan it early, test it thoroughly.
One application, many customers, isolated data.