Django’s multi-tenancy with separate schemas isn’t just about isolating data; it’s about enforcing isolation at the database level, giving each tenant their own schema to prevent cross-tenant data leaks and simplify management.

Let’s see this in action. Imagine a Tenant model and a Client model.

# models.py

from django.db import models

class Tenant(models.Model):
    name = models.CharField(max_length=100, unique=True)
    schema_name = models.CharField(max_length=63, unique=True) # Database schema name

    def __str__(self):
        return self.name

class Client(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)
    email = models.EmailField()

    def __str__(self):
        return f"{self.name} ({self.tenant.name})"

When a new tenant is created, we also create a new PostgreSQL schema for them.

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.db import connection
from .models import Tenant

@receiver(post_save, sender=Tenant)
def create_tenant_schema(sender, instance, created, **kwargs):
    if created:
        with connection.cursor() as cursor:
            cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {instance.schema_name};")
            # You'd typically run migrations here to set up the schema's tables
            # For simplicity, we're just creating the schema.
            # In a real app, use `django-tenants` or similar for full migration management.

Now, how do we ensure requests hit the right schema? We use middleware.

# middleware.py
from django.db import connection
from .models import Tenant

class TenantSchemaMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant_slug = request.META.get('HTTP_X_TENANT_SLUG') # Assumes a custom header
        if tenant_slug:
            try:
                tenant = Tenant.objects.get(schema_name=tenant_slug)
                connection.set_schema(tenant.schema_name)
            except Tenant.DoesNotExist:
                # Handle invalid tenant, perhaps return 404 or 403
                pass # For now, let it fall through

        response = self.get_response(request)
        connection.set_schema(None) # Reset schema after request
        return response

The problem this solves is scaling your Django application to handle many distinct "customers" or "organizations" while keeping their data completely separate. Instead of a single, massive clients table with a tenant_id column, you have a clients table within each tenant’s schema. This means that SELECT * FROM clients in one tenant’s schema will never show data from another tenant’s schema.

Internally, PostgreSQL schemas are just namespaces. When you SET search_path TO my_tenant_schema, public;, any unqualified table references (like Client) will first look for my_tenant_schema.Client. Django’s database connection object lets you set_schema to dynamically change this search_path.

The levers you control are:

  1. Tenant Identification: How do you know which tenant is making the request? Common methods include subdomain (tenant1.yourapp.com), a custom HTTP header (X-Tenant-Slug: tenant1), or a URL path (yourapp.com/tenant1/).
  2. Schema Creation/Management: When a new tenant signs up, you need to create their schema, potentially run initial migrations on it, and set up any necessary defaults. Libraries like django-tenants automate much of this.
  3. Request Routing: Middleware is key to switching the database connection’s active schema based on the identified tenant for the duration of the request.
  4. Tenant-Agnostic Code: Your Django models and views should ideally not need to know about tenant_id columns. They just query Client and get the right data because the schema is already set.

The one thing most people don’t realize is that your "shared" apps, like Django’s built-in auth or admin, often live in the public schema. When you switch schemas, you might temporarily lose access to these unless you explicitly include public in your search_path for every request. A common pattern is SET search_path TO <tenant_schema>, public;, ensuring that both tenant-specific tables and shared system tables are accessible.

The next hurdle is managing tenant-specific static files or media, which often requires a different approach than database schemas.

Want structured learning?

Take the full Django course →