Building a Multi-Tenant SaaS in Django: Complete 2026 Architecture
Multi-tenancy is the backbone of modern SaaS applications. It allows a single application instance to serve multiple customers (tenants) while keeping their data isolated and secure. In this comprehensive guide, we’ll architect a production-ready multi-tenant Django application that scales.
Understanding Multi-Tenancy Approaches Before diving into code, let’s understand the three main approaches to multi-tenancy:
Database per Tenant: Each tenant gets their own database. This offers maximum isolation but increases operational complexity and costs.
Schema per Tenant: All tenants share a database but have separate schemas. This balances isolation with resource efficiency.
Shared Schema with Row-Level Security: All tenants share the same database and schema, with tenant identification in each row. This is the most cost-effective approach for scaling.
For this guide, we’ll implement the shared schema approach with row-level security, as it offers the best balance of scalability, cost-effectiveness, and simplicity for most SaaS applications.
Core Architecture Components Our architecture consists of several key components working together:
The tenant resolution layer identifies which tenant is making the request based on subdomain, custom domain, or API token. The data isolation layer ensures queries automatically filter by tenant. The authentication layer manages user-tenant relationships and permissions. Finally, the API layer provides secure endpoints with proper tenant context.
Project Setup and Dependencies Let’s start by setting up our Django project with the necessary dependencies. We’ll use Django 5.0+, PostgreSQL for our database, Redis for caching, and Celery for background tasks.
pip install django==5.0 psycopg2-binary django-cors-headers pip install djangorestframework djangorestframework-simplejwt pip install celery redis django-redis django-tenant-schemas pip install gunicorn whitenoise pillow
Database Design for Multi-Tenancy The foundation of our multi-tenant system is a well-designed database schema. Every tenant-specific model must include a foreign key to the Tenant model.
# core/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
import uuid
class Tenant(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
subdomain = models.CharField(max_length=63, unique=True, db_index=True)
custom_domain = models.CharField(max_length=255, blank=True, null=True, unique=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Subscription and billing
plan = models.CharField(max_length=50, default='free')
trial_ends_at = models.DateTimeField(null=True, blank=True)
# Settings
settings = models.JSONField(default=dict, blank=True)
class Meta:
db_table = 'tenants'
indexes = [
models.Index(fields=['subdomain']),
models.Index(fields=['custom_domain']),
]
def __str__(self):
return self.name
class User(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='users')
role = models.CharField(max_length=50, default='member')
class Meta:
db_table = 'users'
unique_together = [['email', 'tenant']]
Tenant Resolution Middleware The middleware layer is crucial for automatically detecting and setting the tenant context for each request. This happens before any view logic executes.
# core/middleware.py
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from .models import Tenant
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(MiddlewareMixin):
def process_request(self, request):
tenant = None
# Try to get tenant from subdomain
host = request.get_host().split(':')[0]
parts = host.split('.')
if len(parts) > 2: # subdomain.example.com
subdomain = parts[0]
try:
tenant = Tenant.objects.get(subdomain=subdomain, is_active=True)
except Tenant.DoesNotExist:
pass
# Try custom domain if subdomain didn't work
if not tenant:
try:
tenant = Tenant.objects.get(custom_domain=host, is_active=True)
except Tenant.DoesNotExist:
pass
# Try to get tenant from API token header
if not tenant and 'HTTP_X_TENANT_ID' in request.META:
tenant_id = request.META['HTTP_X_TENANT_ID']
try:
tenant = Tenant.objects.get(id=tenant_id, is_active=True)
except Tenant.DoesNotExist:
pass
if tenant:
set_current_tenant(tenant)
request.tenant = tenant
else:
set_current_tenant(None)
request.tenant = None
return None
def process_response(self, request, response):
set_current_tenant(None)
return response
Automatic Tenant Filtering with Model Manager Instead of manually filtering by tenant in every query, we create a custom model manager that automatically adds tenant filtering to all queries.
# core/managers.py
from django.db import models
from .middleware import get_current_tenant
class TenantManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset()
tenant = get_current_tenant()
if tenant:
return queryset.filter(tenant=tenant)
return queryset.none()
class TenantModel(models.Model):
tenant = models.ForeignKey('core.Tenant', on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = TenantManager()
all_objects = models.Manager() # Unfiltered manager for admin/background tasks
class Meta:
abstract = True
Example Application Models Let’s create a simple application with projects and tasks to demonstrate how tenant isolation works in practice.
# projects/models.py
from django.db import models
from core.models import TenantModel, User
import uuid
class Project(TenantModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='owned_projects')
members = models.ManyToManyField(User, related_name='projects', blank=True)
is_archived = models.BooleanField(default=False)
class Meta:
db_table = 'projects'
ordering = ['-created_at']
def __str__(self):
return self.name
class Task(TenantModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tasks')
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
assignee = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks')
status = models.CharField(max_length=50, default='todo')
priority = models.CharField(max_length=20, default='medium')
due_date = models.DateField(null=True, blank=True)
class Meta:
db_table = 'tasks'
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
]
def __str__(self):
return self.title
REST API with Tenant Context Our API views automatically inherit tenant filtering through the model manager, but we need to ensure users can only access their tenant’s data.
# projects/serializers.py
from rest_framework import serializers
from .models import Project, Task
from core.models import User
class ProjectSerializer(serializers.ModelSerializer):
owner_email = serializers.EmailField(source='owner.email', read_only=True)
task_count = serializers.IntegerField(read_only=True)
class Meta:
model = Project
fields = ['id', 'name', 'description', 'owner', 'owner_email',
'task_count', 'is_archived', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
class TaskSerializer(serializers.ModelSerializer):
assignee_email = serializers.EmailField(source='assignee.email', read_only=True)
class Meta:
model = Task
fields = ['id', 'project', 'title', 'description', 'assignee',
'assignee_email', 'status', 'priority', 'due_date',
'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
# projects/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Count
from .models import Project, Task
from .serializers import ProjectSerializer, TaskSerializer
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Objects are automatically filtered by tenant via TenantManager
queryset = Project.objects.annotate(task_count=Count('tasks'))
# Additional filtering based on user permissions
if not self.request.user.role == 'admin':
queryset = queryset.filter(
models.Q(owner=self.request.user) |
models.Q(members=self.request.user)
).distinct()
return queryset
def perform_create(self, serializer):
serializer.save(
tenant=self.request.tenant,
owner=self.request.user
)
@action(detail=True, methods=['post'])
def archive(self, request, pk=None):
project = self.get_object()
project.is_archived = True
project.save()
return Response({'status': 'project archived'})
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = Task.objects.select_related('project', 'assignee')
# Filter by project if provided
project_id = self.request.query_params.get('project')
if project_id:
queryset = queryset.filter(project_id=project_id)
# Filter by status if provided
status = self.request.query_params.get('status')
if status:
queryset = queryset.filter(status=status)
return queryset
def perform_create(self, serializer):
serializer.save(tenant=self.request.tenant)
Authentication with Tenant Context JWT tokens need to include tenant information to maintain context across requests.
# core/authentication.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
class TenantTokenObtainPairSerializer(TokenObtainPairSerializer):
def validate(self, attrs):
data = super().validate(attrs)
# Add tenant information to token
data['tenant_id'] = str(self.user.tenant.id)
data['tenant_name'] = self.user.tenant.name
data['user_role'] = self.user.role
return data
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# Add custom claims
token['tenant_id'] = str(user.tenant.id)
token['role'] = user.role
return token
class TenantTokenObtainPairView(TokenObtainPairView):
serializer_class = TenantTokenObtainPairSerializer
Tenant Onboarding Flow Creating a new tenant involves several steps: creating the tenant record, setting up the admin user, initializing default settings, and optionally sending welcome emails.
# core/services.py
from django.db import transaction
from django.contrib.auth.hashers import make_password
from .models import Tenant, User
class TenantService:
@staticmethod
@transaction.atomic
def create_tenant(name, subdomain, admin_email, admin_password, plan='free'):
# Create tenant
tenant = Tenant.objects.create(
name=name,
subdomain=subdomain.lower(),
plan=plan,
settings={
'features': {
'max_projects': 10 if plan == 'free' else 1000,
'max_users': 5 if plan == 'free' else 100,
}
}
)
# Create admin user
admin_user = User.objects.create(
tenant=tenant,
username=admin_email,
email=admin_email,
password=make_password(admin_password),
role='admin',
is_staff=True
)
# Initialize default data
TenantService._initialize_defaults(tenant, admin_user)
return tenant, admin_user
@staticmethod
def _initialize_defaults(tenant, admin_user):
# Create default project, settings, etc.
from projects.models import Project
Project.objects.create(
tenant=tenant,
name='Getting Started',
description='Welcome to your first project!',
owner=admin_user
)
URL Configuration Set up your URLs to route to the appropriate views and enable the API endpoints.
# config/urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from core.authentication import TenantTokenObtainPairView
from rest_framework_simplejwt.views import TokenRefreshView
from projects.views import ProjectViewSet, TaskViewSet
router = DefaultRouter()
router.register(r'projects', ProjectViewSet, basename='project')
router.register(r'tasks', TaskViewSet, basename='task')
urlpatterns = [
path('admin/', admin.site.urls),
path('api/auth/login/', TenantTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/', include(router.urls)),
]
Settings Configuration Your Django settings need proper configuration for multi-tenancy, security, and performance.
# config/settings.py (key sections)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.TenantMiddleware', # Our custom tenant middleware
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
AUTH_USER_MODEL = 'core.User'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 50,
}
# Cache configuration
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
},
'KEY_PREFIX': 'saas',
'TIMEOUT': 300,
}
}
Testing Multi-Tenancy
Testing is critical to ensure data isolation works correctly. Here’s how to test tenant separation.
# projects/tests.py
from django.test import TestCase
from core.models import Tenant, User
from core.middleware import set_current_tenant
from projects.models import Project
class TenantIsolationTest(TestCase):
def setUp(self):
# Create two separate tenants
self.tenant1 = Tenant.objects.create(name='Tenant 1', subdomain='tenant1')
self.tenant2 = Tenant.objects.create(name='Tenant 2', subdomain='tenant2')
# Create users for each tenant
self.user1 = User.objects.create(
tenant=self.tenant1,
username='[email protected]',
email='[email protected]'
)
self.user2 = User.objects.create(
tenant=self.tenant2,
username='[email protected]',
email='[email protected]'
)
def test_tenant_isolation(self):
# Create projects for each tenant
set_current_tenant(self.tenant1)
project1 = Project.objects.create(
tenant=self.tenant1,
name='Project 1',
owner=self.user1
)
set_current_tenant(self.tenant2)
project2 = Project.objects.create(
tenant=self.tenant2,
name='Project 2',
owner=self.user2
)
# Verify tenant1 can only see their project
set_current_tenant(self.tenant1)
tenant1_projects = Project.objects.all()
self.assertEqual(tenant1_projects.count(), 1)
self.assertEqual(tenant1_projects.first().id, project1.id)
# Verify tenant2 can only see their project
set_current_tenant(self.tenant2)
tenant2_projects = Project.objects.all()
self.assertEqual(tenant2_projects.count(), 1)
self.assertEqual(tenant2_projects.first().id, project2.id)
def test_cross_tenant_access_blocked(self):
set_current_tenant(self.tenant1)
project1 = Project.objects.create(
tenant=self.tenant1,
name='Project 1',
owner=self.user1
)
# Try to access tenant1's project from tenant2's context
set_current_tenant(self.tenant2)
with self.assertRaises(Project.DoesNotExist):
Project.objects.get(id=project1.id)
Performance Optimization Multi-tenant applications require careful performance optimization. Use database indexes on tenant foreign keys, implement caching strategies per tenant, and use select_related and prefetch_related to minimize queries.
# Example optimized query
projects = (
Project.objects
.select_related('owner', 'tenant')
.prefetch_related('members', 'tasks')
.annotate(task_count=Count('tasks'))
)
# Cache tenant data
from django.core.cache import cache
def get_tenant_settings(tenant_id):
cache_key = f'tenant_settings:{tenant_id}'
settings = cache.get(cache_key)
if settings is None:
tenant = Tenant.objects.get(id=tenant_id)
settings = tenant.settings
cache.set(cache_key, settings, 3600) # Cache for 1 hour
return settings
Security Considerations Security is paramount in multi-tenant systems. Always validate that the tenant context matches the authenticated user’s tenant. Never trust client-side tenant identifiers without server-side verification. Implement proper RBAC within each tenant, and use row-level security as your primary defense.
Regularly audit cross-tenant access attempts, encrypt sensitive data at rest, and implement rate limiting per tenant to prevent abuse.
Monitoring and Observability Track key metrics per tenant including request rates, error rates, database query counts, and storage usage. Set up alerts for unusual cross-tenant activity and monitor query performance degradation.
# Example metrics collection
import logging
logger = logging.getLogger(__name__)
def log_tenant_metric(tenant_id, metric_name, value):
logger.info(
'tenant_metric',
extra={
'tenant_id': tenant_id,
'metric': metric_name,
'value': value
}
)
Scaling Strategies As your SaaS grows, consider these scaling strategies:
Use database read replicas for large tenants, implement horizontal sharding when a single database becomes a bottleneck, and move large tenants to dedicated databases while keeping small tenants on shared infrastructure. Consider using a CDN for static assets and implement API rate limiting per tenant tier.
Deployment Checklist Before deploying to production, ensure you have proper database backups with tenant-aware restore procedures, SSL/TLS for all domains and subdomorms, tenant-specific rate limiting configured, comprehensive monitoring and alerting, and a disaster recovery plan that accounts for tenant data isolation.
Conclusion Building a multi-tenant SaaS application in Django requires careful architecture and attention to data isolation. By implementing tenant middleware, custom model managers, and proper authentication, you can create a secure and scalable platform.
The shared schema approach we’ve covered provides the best balance of isolation, performance, and operational simplicity for most SaaS applications. As your application grows, you can selectively move larger tenants to dedicated infrastructure while keeping the majority on shared resources.
Remember that security and data isolation should never be compromised. Always test your tenant boundaries thoroughly and monitor for any cross-tenant access attempts.
The complete architecture we’ve built provides a solid foundation for building any B2B SaaS application in 2026 and beyond.
Read the full article here: https://medium.com/@yogeshkrishnanseeniraj/building-a-multi-tenant-saas-in-django-complete-2026-architecture-e956e9f5086a