Skip to content
Codeloom
Django

Django Multi-Tenancy: Shared Database Schema Patterns

Implement multi-tenancy in Django using shared database schemas — tenant models, middleware, filtered QuerySets, and data isolation.

·6 min read · By Codeloom
Intermediate 13 min read

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.