djust docs
Browse documentation

Multi-Tenant Applications

Build SaaS apps with TenantMixin, flexible resolution strategies, and tenant-scoped querysets and presence

On this page

djust provides comprehensive multi-tenant support for building SaaS applications with row-level tenant isolation, flexible resolution strategies, and tenant-scoped data access.

Choosing Your Multi-Tenancy Strategy

djust has one supported multi-tenancy strategy: djust.tenants (row-level). This guide covers it. If you're starting a new djust application, this is the path.

djust.tenants (row-level) — the supported ASGI-native path

Every tenant-scoped row carries a tenant_id. Querysets filter on it. Tenant resolution at request/connection time is a single lookup, cached for the rest of the request. No schema switching. No SET search_path. No per-event Postgres roundtrip.

This is the recommended and only supported multi-tenancy strategy for djust applications because:

  • LiveView's hot path (every WS event re-enters middleware) stays cheap — there's no DB roundtrip on entry.
  • Connection-pool pressure scales with active sessions, not events × tenants × middleware passes.
  • Tenant resolution is cacheable in-process or in Redis without needing to compose with database connection state.
  • Single Postgres schema is operationally simpler — one set of migrations, one backup, one set of indexes.
  • It integrates cleanly with djust's presence layer and TenantMixin / TenantScopedMixin.

django-tenants (schema-per-tenant) — DEPRECATED under djust

Deprecated. Integration with the external django-tenants library (schema-per-tenant via SET search_path) is deprecated as a multi-tenancy strategy for djust applications. Existing deploys are not forcibly broken, but new applications should not adopt it, and existing applications should plan a migration to djust.tenants.

Why deprecated:

  • It is a known production footgun under ASGI + LiveView. Every WebSocket event re-enters TenantMainMiddleware → set_tenant() → SET search_path. LiveView amplifies this dramatically: tick_interval polling (typically every 1.5–5s), push_to_view re-mounts across all connected sessions, presence updates, and @notify_on_save listener re-mounts each re-enter the middleware. Without the TENANT_LIMIT_SET_CALLS stopgap (below) this exhausts the Postgres connection pool. Issue #1556 was a real production 503 incident on a djust deploy traceable to this shape.
  • Schema-per-tenant isolation is not what most multi-tenant SaaS applications actually need. tenant_id filtering at the row level satisfies the typical "users in tenant A cannot see tenant B's data" requirement, and is the model djust's own mixins and presence layer integrate with natively.
  • The migration path is straightforward for typical applications: add tenant_id columns, write a one-time data migration to copy schema-qualified rows into the shared schema with the right tenant_id, swap middleware. See the dedicated step-by-step recipe in Migrating from django-tenants (mental-model translation, data migration, code/settings diffs, rollout, and a cross-tenant-leak canary test).

Stopgap (until migration): required settings if you remain on django-tenants

If you cannot migrate immediately and are running an existing djust + django-tenants deploy, the stopgap configuration to avoid the connection-storm class of bug is:

# settings.py — stopgap only; migrate to djust.tenants for long-term support

# Skip the redundant SET search_path wire trip when the connection is
# already on the right tenant. WITHOUT THIS your deploy is one
# tick_interval-heavy LiveView away from exhausting Postgres.
TENANT_LIMIT_SET_CALLS = True

djust ships a startup system check (djust.C014) that warns when django_tenants is configured + ASGI_APPLICATION is set + TENANT_LIMIT_SET_CALLS is unset or False. The check's hint and fix_hint lead with the migration recommendation; the TENANT_LIMIT_SET_CALLS = True setting is described as a stopgap, not a long-term fix.

Additional operational recommendations for the stopgap window:

  • Size Postgres max_connections for at least replicas × ASGI_THREAD_LIMIT plus headroom for Celery/admin/migrations. The default max_connections = 100 is typically too tight for a 2-replica ASGI deploy.
  • Put a transaction-pooling pgbouncer in front of Postgres for any multi-replica deployment. ASGI's per-request threadpool model multiplies real Postgres connections beyond what worker-process pooling does under WSGI.
  • Plan a migration to djust.tenants. The longer the stopgap window, the more risk of regression as djust framework features evolve without active django-tenants integration testing in CI.

The remainder of this guide covers djust.tenants.

What You Get

  • Automatic tenant resolution -- From subdomain, path, headers, session, or custom logic
  • TenantMixin / TenantScopedMixin -- Tenant-aware LiveViews with scoped querysets
  • Tenant-scoped presence -- TenantMixin prefixes presence keys per tenant
  • Chained resolution -- Try multiple strategies with fallback
  • Template context -- Automatic tenant injection

Quick Start

1. Configure Tenant Resolution

# settings.py
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'subdomain',  # or 'path', 'header', 'session', 'custom'
    'TENANT_MAIN_DOMAIN': 'myapp.com',
    'TENANT_SUBDOMAIN_EXCLUDE': ['www', 'api', 'admin'],
    'TENANT_DEFAULT': 'public',
}

2. Use TenantMixin in Your Views

from djust import LiveView
from djust.tenants import TenantMixin, TenantScopedMixin

class DashboardView(TenantScopedMixin, LiveView):
    template_name = 'dashboard.html'

    def mount(self, request, **kwargs):
        # self.tenant is automatically available
        self.users_count = self.get_tenant_queryset(User).count()
        self.projects = self.get_tenant_queryset(Project).order_by('-created_at')[:5]

3. Tenant-Scoped Models

class TenantScopedModel(models.Model):
    tenant_id = models.CharField(max_length=50, db_index=True)

    class Meta:
        abstract = True

class Project(TenantScopedModel):
    name = models.CharField(max_length=100)
    created_at = models.DateTimeField(auto_now_add=True)

Resolution Strategies

Subdomain

# acme.myapp.com -> tenant_id: "acme"
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'subdomain',
    'TENANT_MAIN_DOMAIN': 'myapp.com',
    'TENANT_SUBDOMAIN_EXCLUDE': ['www', 'api'],
    'TENANT_DEFAULT': 'public',
}

Path

# myapp.com/acme/dashboard -> tenant_id: "acme"
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'path',
    'TENANT_PATH_POSITION': 1,  # 1-based: first path segment after /
    'TENANT_PATH_EXCLUDE': ['admin', 'api', 'static'],
    'TENANT_DEFAULT': 'public',
}
# X-Tenant-ID: acme -> tenant_id: "acme"
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'header',
    'TENANT_HEADER': 'X-Tenant-ID',
    'TENANT_DEFAULT': 'public',
}

Session / JWT

DJUST_CONFIG = {
    'TENANT_RESOLVER': 'session',
    'TENANT_SESSION_KEY': 'tenant_id',  # session key to read
    'TENANT_JWT_CLAIM': 'tenant_id',    # JWT claim, if request.user has jwt_payload
}

The session resolver also falls back to a user.tenant_id model attribute when neither the session key nor a JWT claim is present.

Custom

def custom_tenant_resolver(request):
    from djust.tenants.resolvers import TenantInfo
    tenant_id = request.user.organization.slug if request.user.is_authenticated else 'public'
    return TenantInfo(tenant_id=tenant_id, name=tenant_id, settings={'theme': 'blue'})

# settings.py
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'custom',
    'TENANT_CUSTOM_RESOLVER': 'myapp.utils.custom_tenant_resolver',  # dotted path
}

Chained (Fallback)

# Set TENANT_RESOLVER to a LIST of short names; each is tried in order
# and the first successful match wins (chained resolution).
DJUST_CONFIG = {
    'TENANT_RESOLVER': ['header', 'subdomain', 'session'],
    'TENANT_DEFAULT': 'public',
}

Mixins

TenantMixin

Base mixin that resolves and injects self.tenant and adds it to template context.

class MyView(TenantMixin, LiveView):
    def mount(self, request, **kwargs):
        logger.info("Mounted for tenant: %s", self.tenant.name)

TenantScopedMixin

Extends TenantMixin with scoped querysets:

class ProjectListView(TenantScopedMixin, LiveView):
    model = Project  # used as the default for the helpers below

    def mount(self, request, **kwargs):
        self.projects = self.get_tenant_queryset()

    def get_project(self, project_id):
        return self.get_tenant_object(project_id)
MethodDescription
get_tenant_queryset(model=None)Queryset filtered by current tenant (defaults to self.model)
get_tenant_object(pk, model=None)Get a single object by pk, scoped to the current tenant
create_for_tenant(model=None, **fields)Create an instance with tenant_id stamped automatically

The tenant field defaults to tenant_id; override it per view with the tenant_field class attribute.

Switching tenant at runtime (set_tenant) and WebSocket persistence

Sometimes a user picks their tenant after the page has mounted — an organization switcher, an onboarding step, an "act as tenant" admin control. Use self.set_tenant(tenant_id) from an event handler:

from djust import LiveView
from djust.decorators import event_handler
from djust.tenants import TenantMixin

class TenantSwitcherView(TenantMixin, LiveView):
    tenant_required = False  # allow mount before a tenant is chosen — see below

    @event_handler()
    def choose_tenant(self, value: str = "", **kwargs):
        self.set_tenant(value)      # authoritative: updates self.tenant now
        self.projects = Project.objects.filter(tenant_id=self.tenant.id)

set_tenant():

  • Updates the in-memory view state (self.tenant) immediately. This is the authoritative source of truth for the tenant for the rest of the WebSocket connection.
  • Best-effort mirrors the id into request.session[TENANT_SESSION_KEY] only when a session resolver is configured (TENANT_RESOLVER: 'session', or a chained resolver that includes 'session'). For any other resolver (subdomain / path / header / custom) the session write is a no-op — those strategies don't read the session, so writing it would be misleading.

Why the session write is only a mirror, not the source of truth

SessionResolver.resolve() is read-only — it only reads request.session[TENANT_SESSION_KEY] (falling back to a JWT claim, then request.user.tenant_id). It never writes the session.

Critically, a request.session[...] = ... write performed inside a WebSocket event handler has no built-in save guarantee. Django's SessionMiddleware persists the session when it processes the HTTP response — but a LiveView event arrives over the WebSocket, and there is no HTTP response for the middleware to save against. So a session write done during an event may never reach the session store.

The robust pattern is therefore:

  • Keep the tenant id in view state (self.tenant, and — if you're adapting an existing model FK — your own self.<field>) as the authoritative value for the connection's lifetime.
  • Treat any session write as a mirror only — a convenience so the next full-page (HTTP) load resolves the same tenant via the session resolver. set_tenant() already does exactly this for you when a session resolver is configured.

TENANT_REQUIRED runs before mount()

TenantMixin resolves the tenant before mount() runs, on every transport. Over HTTP that happens in dispatch() / get() / post(); on the WebSocket path it is step 3 of the shared pre-mount sequence (run_pre_mount_auth, auth/core.py), which the WebSocket consumer calls before mount() because the HTTP dispatch chain is not entered. When the tenant cannot be resolved and it is required (tenant_required = True on the view, or DJUST_CONFIG['TENANT_REQUIRED'], which defaults to True), the mixin raises Http404 — so the view 404s before mount() is ever called. That means mount() cannot resolve a default tenant for a fresh session under the default configuration.

If you need to mount without a pre-resolved tenant (e.g. an org switcher, or when you're adapting an existing tenant FK and want to resolve it yourself), set tenant_required = False on the view and resolve the tenant manually in mount() (or later, via set_tenant()):

class OnboardingView(TenantMixin, LiveView):
    tenant_required = False   # don't 404 before mount() when no tenant yet

    def mount(self, request, **kwargs):
        # Resolve your own default when none was found by the resolver.
        if self.tenant is None and request.user.is_authenticated:
            self.set_tenant(request.user.organization.slug)

View state and tenants

There is no tenant-aware state backend. DJUST_STATE_BACKEND (or DJUST_CONFIG['STATE_BACKEND']) accepts only 'redis' (or a redis:// URL) and 'memory'; any other value, including a dotted path to a class, silently falls back to the in-process memory backend. Don't point it at the classes in djust.tenants.backends: those are presence backends, not state backends.

View state is keyed by the user's session and the page path, not by tenant, so two users never share view state, whichever tenant they belong to. The keys don't include the tenant id, though. If one session can reach several tenants on the same URL (a header or session resolver, or set_tenant()), don't rely on the state cache to separate them: include the tenant in the URL, or re-derive tenant data in the event handler. TenantMixin.get_state_key_prefix() returns tenant:<id>, but no built-in state backend calls it at this release; it is a hook for your own storage (see #2973).

Tenant-Aware Presence

Presence has its own tenant-aware backends, in djust.tenants.backends:

from djust.tenants.backends import (
    TenantAwareBackendMixin,       # prefixes every key `tenant:<id>:<key>`
    TenantAwareRedisBackend,       # PresenceBackend + tenant scoping
    TenantAwareMemoryBackend,      # PresenceBackend + tenant scoping
    get_tenant_presence_backend,   # (tenant_id: str) -> PresenceBackend
)

TenantAwareBackendMixin is a mixin, not a standalone backend — it is what gives the two backend classes their key prefixing, so use one of those rather than mixing it in yourself.

PresenceMixin views: TenantMixin already scopes them. A view that has both mixins stores its presence under tenant:<id>:<key>, so two tenants' users land in different presence groups. Since 1.2.1 the base order doesn't matter: PresenceMixin.get_presence_key() applies the same prefix when it comes first (#2973). Before 1.2.1, listing PresenceMixin first dropped the prefix.

from djust import LiveView
from djust.presence import PresenceMixin
from djust.tenants import TenantMixin

class CollaborationView(TenantMixin, PresenceMixin, LiveView):
    presence_key = "lobby"   # stored as tenant:<id>:lobby

Per-tenant backend objects. For code that talks to presence directly, use get_tenant_presence_backend(tenant_id). It returns a cached, tenant-scoped backend with the standard presence API (join / leave / list / count / heartbeat). Call it where you need it rather than storing it on the view: a backend object in public view state is a non-serializable service instance.

from djust.tenants.backends import get_tenant_presence_backend

class CollaborationView(TenantMixin, LiveView):
    def _presence(self):
        return get_tenant_presence_backend(self.tenant.id)

Choosing the backend. PRESENCE_BACKEND takes a short name, not a dotted path. Set it to 'redis' for a multi-process deploy: both the global presence registry (used by PresenceMixin) and get_tenant_presence_backend() then use Redis:

# settings.py
DJUST_CONFIG = {
    "PRESENCE_BACKEND": "redis",
    "PRESENCE_REDIS_URL": "redis://localhost:6379/2",
}

'tenant_redis' and 'tenant_memory', the values djust.tenants documents, are accepted too and select the same storage as 'redis' and 'memory': the tenant scope is in the presence key (above). Before 1.2.1 the global registry turned 'tenant_redis' into in-process memory (#2973).

Any other value, including a dotted path such as 'djust.tenants.backends.TenantAwareRedisBackend', falls back to in-process memory, which isn't shared between workers. The registry logs a warning and the djust.C019 system check flags it at startup.

Template Context

Tenant info is automatically available in templates:

<h1>{{ tenant.name }} Dashboard</h1>

{% if tenant.settings.custom_branding %}
<style>
    :root { --primary-color: {{ tenant.settings.primary_color }}; }
</style>
{% endif %}

<span>Plan: {{ tenant.settings.plan_type|default:"Free" }}</span>

Security Considerations

  • Always scope queries to the current tenant using TenantScopedMixin or manual filtering.
  • Index tenant_id fields in the database for query performance.
  • Validate URL access to prevent cross-tenant data access:
from django.core.exceptions import PermissionDenied
from django.urls import Resolver404, resolve


class TenantAccessMixin:
    """Deny a URL whose tenant_slug is not the request's tenant.

    Combine with TenantMixin / TenantScopedMixin (it uses resolve_tenant()).
    """

    def check_permissions(self, request):
        # self.kwargs is not set on the WebSocket mount path; read the URL
        # kwargs from the request path, which works on every transport.
        try:
            tenant_slug = resolve(request.path_info).kwargs.get("tenant_slug")
        except Resolver404:
            tenant_slug = None
        if tenant_slug is None:
            return True
        # check_permissions() runs BEFORE TenantMixin resolves the tenant on
        # the WebSocket/SSE paths, so self._tenant may still be None here.
        tenant = self._tenant or self.resolve_tenant(request)
        if tenant is None or tenant.id != tenant_slug:
            raise PermissionDenied("Access denied")
        return True

check_permissions() runs before TenantMixin resolves the tenant. In the pre-mount sequence, auth (including check_permissions()) is step 1 and tenant resolution is step 3, so on the WebSocket and SSE paths self._tenant is still None inside the hook. Resolve the tenant yourself there, as above. A hook that only reads self._tenant denies every WebSocket mount.

Use check_permissions(), not a dispatch() override. It runs inside the shared pre-mount sequence (run_pre_mount_auth, auth/core.py) that every mount path calls — WebSocket, the runtime dispatch, and SSE. The WebSocket path calls mount() directly and never enters Django's HTTP dispatch() chain, so a dispatch-based check silently does not run for a LiveView — the page loads unprotected and nothing raises. login_required and permission_required are applied at the same point for the same reason.

Testing

from djust.tenants import TenantScopedMixin
from djust.tenants.resolvers import TenantInfo
from django.test import RequestFactory


def test_tenant_scoped_query(db):
    # Query through the same scoping code the views use, and check the
    # other tenant's rows are absent. (A hand-written
    # .filter(tenant_id='acme') would pass whether or not scoping works.)
    Project.objects.create(tenant_id='acme', name='A')
    Project.objects.create(tenant_id='globex', name='G')

    class _Probe(TenantScopedMixin):
        model = Project

        def __init__(self):
            self._tenant = TenantInfo(tenant_id='acme')
            self._tenant_resolved = True

    projects = _Probe().get_tenant_queryset()
    assert projects.count() == 1
    assert not projects.filter(tenant_id='globex').exists()


def test_view_with_tenant():
    request = RequestFactory().get('/dashboard/')
    request.tenant = TenantInfo(tenant_id='test', name='Test Org')
    view = DashboardView()
    view.setup(request)
    # In production TenantMixin runs the configured resolver on the request
    # (resolve_tenant()) before mount(); here we set the tenant directly.
    view.tenant = request.tenant
    assert view.tenant.id == 'test'

Best Practices

  • Use TenantScopedMixin for all data-accessing views to prevent cross-tenant leakage.
  • Include tenant_id in cache keys to prevent data bleed through caching.
  • Consider separate database connection pools per tenant for heavy workloads.
  • Use select_related / prefetch_related with tenant-scoped querysets for performance.