Rohan Yeole - Homepage Rohan Yeole

Multi-Tenant Django Architecture: Schemas, Row-Level, and Subdomain Patterns

Apr 21, 20261 min read

Most SaaS applications need multi-tenancy — the ability to serve multiple organizations (tenants) from a single application, with data isolation between them. Django supports all three main patterns. The choice between them determines your isolation guarantees, scalability ceiling, and operational complexity.

The Three Patterns

1. Shared Schema with tenant_id (Row-Level Tenancy)

Every tenant's data lives in the same tables, distinguished by a tenant_id foreign key.

class Tenant(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)

class Project(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    name = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["tenant", "created_at"]),
        ]

Every query must filter by tenant. The risk: forgetting the filter returns another tenant's data. Mitigate this with a custom manager:

from django.db import models

class TenantManager(models.Manager):
    def for_tenant(self, tenant):
        return self.filter(tenant=tenant)

class Project(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    objects = TenantManager()

Middleware that sets the current tenant on the request:

# middleware.py
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        subdomain = request.get_host().split(".")[0]
        try:
            request.tenant = Tenant.objects.get(slug=subdomain)
        except Tenant.DoesNotExist:
            request.tenant = None
        return self.get_response(request)

Use shared schema when: - You have fewer than 500 tenants - Isolation requirements are low (no regulatory requirement for data separation) - You want the simplest possible implementation - Tenants have similar data volumes

Risks: - A missing tenant filter in one query leaks data across tenants - A slow query from one tenant affects all tenants - Backup/restore for a single tenant requires filtered exports

2. Separate PostgreSQL Schemas (django-tenants)

Each tenant gets their own PostgreSQL schema — a namespace within the same database. Tables are identical in structure but completely isolated by schema.

pip install django-tenants
# settings.py
INSTALLED_APPS = [
    "django_tenants",
    # ...
]

DATABASES = {
    "default": {
        "ENGINE": "django_tenants.postgresql_backend",
        # Standard PostgreSQL config
    }
}

DATABASE_ROUTERS = ["django_tenants.routers.TenantSyncRouter"]

TENANT_MODEL = "tenants.Client"
TENANT_DOMAIN_MODEL = "tenants.Domain"

SHARED_APPS = [
    "django_tenants",
    "tenants",
    "django.contrib.auth",
    # Apps whose tables live in the public schema
]

TENANT_APPS = [
    "projects",
    "invoices",
    # Apps whose tables are replicated per tenant schema
]

django-tenants automatically switches the PostgreSQL search_path based on the current tenant. Queries hit tenant-specific tables without any changes to your code.

# Creating a tenant
from tenants.models import Client, Domain

tenant = Client(name="Acme Corp", schema_name="acme", paid_until="2027-01-01")
tenant.save()  # Creates the acme schema and runs all migrations in it

domain = Domain(domain="acme.yourapp.com", tenant=tenant, is_primary=True)
domain.save()

Use separate schemas when: - Regulatory requirements mandate data isolation (healthcare, finance, legal) - Tenants need independent backup/restore capability - You have tenants who pay for guaranteed isolation - Tenant count is in the hundreds (not tens of thousands)

Risks: - Schema migrations must run across all tenant schemas — slow at scale - django-tenants adds complexity to deployment and management commands - Cross-tenant queries (aggregate analytics) require accessing multiple schemas

3. Subdomain Routing (Application-Level Routing)

Map subdomains to tenants in middleware and use either shared or separate schemas underneath. The routing is the same regardless of the data isolation pattern:

# middleware.py
class SubdomainTenantMiddleware:
    def __call__(self, request):
        host = request.get_host().lower()
        # host = "acme.yourapp.com"
        subdomain = host.split(".")[0]  # "acme"

        if subdomain not in ("www", "yourapp"):
            try:
                request.tenant = Tenant.objects.select_related().get(slug=subdomain)
            except Tenant.DoesNotExist:
                from django.http import Http404
                raise Http404("Tenant not found")

        return self.get_response(request)

For django-tenants, subdomain routing is built-in via the Domain model.

Use subdomain routing when: - Your product's branding requires acme.yourapp.com patterns - You want to support custom domains (map crm.acme.com → Acme's tenant) - You are building a white-label product

Decision Framework

FactorShared SchemaSeparate SchemasFull Isolation
Tenant countHundreds–thousandsTens–hundredsAny
Data isolation requirementLowMedium–highStrict (regulatory)
Setup complexityLowMediumHigh
Cross-tenant analyticsEasyHardVery hard
Per-tenant backupHardEasyEasy
Migration time at scaleFastSlow (runs N times)Depends

Hybrid Pattern: Shared Database, Encrypted Columns

For high-compliance scenarios without separate schemas, field-level encryption provides isolation:

from django_encryption.fields import EncryptedTextField

class PatientRecord(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    diagnosis = EncryptedTextField()  # Encrypted at rest

Each tenant's data is encrypted with a tenant-specific key. A database breach exposes ciphertext, not plaintext. Key management (typically in a secrets manager) is the operational overhead.

What Most SaaS Products Actually Need

For a new SaaS application with under 200 tenants and no regulatory data separation requirement: start with shared schema. It is the simplest, easiest to reason about, and easiest to migrate away from if you outgrow it. Add the composite index (tenant, created_at) on every major table and use a tenant-aware base manager.

If you reach 500+ tenants with performance complaints from large tenants affecting smaller ones, migrate to separate schemas. This is a significant migration — plan for it when you hit 300 tenants, not when you're in the middle of a performance crisis.

If you are building for healthcare (HIPAA), finance (PCI-DSS), or legal sectors from day one: separate schemas from the start.

If you need a multi-tenant Django SaaS application architected correctly — with the right isolation pattern for your specific requirements — hire me as a Django developer or as a full stack developer.

Chat with me on WhatsApp