All posts

Blog

Multi-tenant Django REST Framework

April 25, 2026

A practical guide to organization scoping in Django REST Framework. Two battle-tested approaches — explicit request-layer scoping and automatic thread-local scoping — with data layer enforcement that makes the wrong thing impossible.

What is multi-tenancy?

Multi-tenancy means one application serves multiple independent customers — called tenants — on shared infrastructure. Each tenant's data must be completely isolated from every other tenant's. A user belonging to Company A must never see Company B's records, even if they hit the same endpoint.

In Django, the most common pattern is row-level tenancy: all tenants share the same database tables, and every row has a foreign key to its owner — usually an Organization, Workspace, or Team. Every query that touches tenant data must filter by that key.

This sounds simple. It isn't. The danger is not the queries you write correctly — it's the one you forget.


The data model

Before writing any view code, we need a clear model for how users relate to organizations. The most flexible approach is a membership table — a through model that connects users to organizations and carries additional context like a role.

class Organization(models.Model):
    name  = models.CharField(max_length=255)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="owned_orgs",
    )


class Membership(models.Model):
    class Role(models.TextChoices):
        OWNER  = "owner",  "Owner"
        ADMIN  = "admin",  "Admin"
        MEMBER = "member", "Member"

    user         = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="memberships",
    )
    organization = models.ForeignKey(
        Organization,
        on_delete=models.CASCADE,
        related_name="members",
    )
    role       = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = [("user", "organization")]

Why a membership table instead of a direct user.organization FK? Because a user can belong to multiple organizations — a contractor, an agency employee, a platform admin. The membership table handles this naturally and gives you role-per-org for free.

For any given request, we resolve one active organization for that user. How you pick which one depends on your product — it could be a header, a subdomain, a URL param, or simply the first membership. The examples below use the first membership for clarity, but the resolution strategy is fully swappable.


The problem with the naive approach

The obvious implementation is to just filter everywhere:

class EmployeeViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        org = self.request.user.memberships.first().organization
        return Employee.objects.filter(organization=org)

This works for this one viewset. But:

  • A new developer writes a management command and forgets the filter — all tenants' data is returned
  • A signal handler queries Employee.objects.filter(is_active=True) — no org scope
  • A utility function called from three places adds the filter in two of them
  • Six months later, a bug report: "I can see another company's records"

The naive approach makes the wrong thing easy. What we want is a system where forgetting the org scope is impossible — or at least, impossible to do silently.

We enforce this at two layers: the data layer (the ORM) and the view layer (DRF).


The data layer: OrgAwareModel

The first line of defense is the ORM itself. We override QuerySet.all() and QuerySet.filter() to raise immediately if no organization is provided — not a silent empty queryset, but a loud, traceable exception.

# core/models.py
from django.core.exceptions import ImproperlyConfigured
from django.db import models


class OrgAwareQuerySet(models.QuerySet):
    def all(self):
        raise ImproperlyConfigured(
            "Cannot do unscoped queries on OrgAware models. "
            "Use .filter(organization=org) or Model.objects_all for trusted access."
        )

    def filter(self, *args, **kwargs):
        if "organization" not in kwargs and "organization_id" not in kwargs:
            raise ImproperlyConfigured(
                "OrgAware filter requires 'organization' or 'organization_id'."
            )
        return super().filter(*args, **kwargs)


class OrgAwareManager(models.Manager):
    def get_queryset(self):
        return OrgAwareQuerySet(self.model, using=self._db)


class OrgAwareModel(models.Model):
    organization = models.ForeignKey(
        "yourapp.Organization",   # swap to your Organization model path
        on_delete=models.CASCADE,
        related_name="%(class)s_set",
    )

    objects     = OrgAwareManager()
    objects_all = models.Manager()  # for admin, migrations, trusted unscoped access

    class Meta:
        abstract = True

Any model that belongs to a tenant just inherits this:

class Employee(OrgAwareModel):
    first_name = models.CharField(max_length=100)
    last_name  = models.CharField(max_length=100)
    is_active  = models.BooleanField(default=True)
    # organization is inherited — no need to define it again

A few things worth noting:

No extra DB hit. QuerySet is lazy. Overriding all() and filter() manipulates only the query object in Python. Nothing hits the database until you evaluate the queryset.

objects_all is intentional. Django internals, admin, and migrations all need unscoped access. Anyone reaching for Employee.objects_all is making a conscious decision to bypass org scope — easy to grep for in code review.

%(class)s in related_name is Django's built-in placeholder for abstract models. It generates a unique reverse accessor per child: organization.employee_set, organization.invoice_set, etc. No clashes across models.

When not to use OrgAwareModel: Models with a OneToOneField to Organization — like a settings singleton — do not need it. You would never do Settings.objects.filter(organization=org) in a loop; you access it via org.settings. Add the managers directly on those models instead.


Approach 1 — Explicit scoping with OrgMixin

The view layer approach resolves the current organization from the request and injects it explicitly. Everything is traceable — no magic, no globals.

The mixin

# core/mixins.py
from rest_framework.exceptions import PermissionDenied


class OrgMixin:
    def initial(self, request, *args, **kwargs):
        super().initial(request, *args, **kwargs)
        if not self.get_org():
            raise PermissionDenied("You are not a member of any organization.")

    def get_org(self):
        cache_key = "_current_org"
        if not hasattr(self.request, cache_key):
            org = self._resolve_org(self.request)
            setattr(self.request, cache_key, org)
        return getattr(self.request, cache_key)

    def _resolve_org(self, request):
        """
        Override this to change how the org is resolved per project.
        Default: first membership of the authenticated user.
        """
        membership = (
            request.user.memberships
            .select_related("organization")
            .first()
        )
        return membership.organization if membership else None

    def perform_create(self, serializer):
        serializer.save(
            created_by=request.user,
            organization=self.get_org(),
        )

initial() is called by DRF before get_queryset() or any action — it is designed for pre-flight checks like auth and permissions. Overriding it here means the org check runs once, early, before anything else. If the user has no membership, they get a clean 403 rather than an empty queryset or a None write to the database.

get_org() caches the resolved org on the request object itself. Django runs one thread per request, so this is safe — subsequent calls within the same request hit no database.

_resolve_org() is extracted as an overridable method. If your app resolves the org from a subdomain, a header, or a URL parameter, override just that method without touching anything else:

class SubdomainOrgMixin(OrgMixin):
    def _resolve_org(self, request):
        subdomain = request.get_host().split(".")[0]
        return Organization.objects.filter(slug=subdomain).first()

OrgModelViewSet and OrgAPIView

Since most views will use this pattern, there is no reason to repeat OrgMixin, viewsets.ModelViewSet on every class. Roll them into convenience base classes:

# core/views.py
from rest_framework import viewsets
from rest_framework.views import APIView
from .mixins import OrgMixin


class OrgModelViewSet(OrgMixin, viewsets.ModelViewSet):
    pass


class OrgAPIView(OrgMixin, APIView):
    pass

OrgMixin stays as the single source of truth. OrgModelViewSet and OrgAPIView are just convenience wrappers — one place to change if anything ever needs to be added to the base.

Usage

from core.views import OrgModelViewSet
from rest_framework.permissions import IsAuthenticated


class EmployeeViewSet(OrgModelViewSet):
    serializer_class   = EmployeeSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        org = self.get_org()  # always valid — initial() already checked
        qs  = Employee.objects.filter(organization=org)

        q      = self.request.query_params.get("q")
        active = self.request.query_params.get("active", "1")

        if q:
            qs = qs.filter(
                Q(first_name__icontains=q) |
                Q(last_name__icontains=q)
            )
        if active == "1":
            qs = qs.filter(is_active=True)

        return qs.distinct()

By the time get_queryset() runs, get_org() is guaranteed to return a real org. The initial() guard already handled the None case upstream.


Approach 2 — Thread-local automatic scoping

Sometimes org context is needed outside the view layer — in model methods, signals, utility functions, or services that have no access to request. The view-layer mixin cannot help there. The solution is a thread-local store.

The utils

# core/tenant.py
import threading

_thread_locals = threading.local()


def get_current_organization():
    return getattr(_thread_locals, "organization", None)


def set_current_organization(org):
    _thread_locals.organization = org


def clear_current_organization():
    _thread_locals.organization = None

threading.local() stores data per-thread. Each request in sync Django runs in its own thread, so _thread_locals.organization is completely isolated per request — no cross-request leaking.

The middleware

# core/middleware.py
from .tenant import set_current_organization, clear_current_organization


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

    def __call__(self, request):
        org = None
        if request.user.is_authenticated:
            org = self._resolve_org(request)

        set_current_organization(org)
        response = self.get_response(request)
        clear_current_organization()

        return response

    def _resolve_org(self, request):
        membership = (
            request.user.memberships
            .select_related("organization")
            .first()
        )
        return membership.organization if membership else None

Register it in settings.py after the authentication middleware:

MIDDLEWARE = [
    ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "core.middleware.TenantMiddleware",
    ...
]

The flow: request arrives → middleware sets the org on the thread → the entire request lifecycle has get_current_organization() available → response goes out → middleware clears it so the thread is clean for the next request.

The TenantManager

# core/models.py
from django.db import models
from .tenant import get_current_organization


class TenantManager(models.Manager):
    def get_queryset(self):
        org = get_current_organization()
        if org is None:
            return super().get_queryset().none()
        return super().get_queryset().filter(organization=org)


class TenantAwareModel(models.Model):
    organization = models.ForeignKey(
        "yourapp.Organization",
        on_delete=models.CASCADE,
        db_index=True,
    )

    objects     = TenantManager()
    objects_all = models.Manager()

    class Meta:
        abstract = True

With this in place, Employee.objects.all() automatically returns only the current tenant's employees — no explicit .filter(organization=org) needed anywhere in your views or services.


When to use Approach 1

  • Org context only needs to exist inside DRF views
  • You want explicit, traceable org resolution
  • You have or plan to have async views or Celery tasks
  • You want org resolution to be overridable per-viewset or per-project

When to use Approach 2

  • You need org context deep in the stack — model methods, signals, utility functions
  • Your stack is purely synchronous (no async views, no Celery)
  • Zero-boilerplate auto-scoping matters more than explicit control

Using both together

The two approaches can coexist — they serve different layers. The key is not letting them become two competing sources of truth. If you run both, have get_org() read from the thread-local:

def get_org(self):
    cache_key = "_current_org"
    if not hasattr(self.request, cache_key):
        from core.tenant import get_current_organization
        setattr(self.request, cache_key, get_current_organization())
    return getattr(self.request, cache_key)

The middleware sets it once. The view-layer mixin reads from it. One source of truth, two consumption patterns — views use get_org(), everything else uses get_current_organization() directly.


Summary

Multi-tenancy in Django is not a single feature — it is a discipline applied at every layer of the stack.

  • The data layer (OrgAwareModel) makes unscoped queries impossible by default. Use objects_all only when you deliberately need unscoped access.
  • The view layer (OrgMixin) resolves the org from the request once, caches it, guards the entire viewset with a 403 if no org is found, and injects it automatically on create.
  • The thread-local layer (TenantMiddleware) makes org context available anywhere in a sync stack, at the cost of traceability and async compatibility.

Pick the approach that matches your stack. Combine them if you need both reach and explicitness. The goal in either case is the same: make the wrong thing — an unscoped query, a cross-tenant write — loud and obvious rather than silent and dangerous.

Back to all posts