Building Multi-Tenant Apps with Django

python django architecture

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

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:

Multi-tenancy is an architectural decision. Plan it early, test it thoroughly.


One application, many customers, isolated data.

All posts