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.

Saved view state is keyed by the user's session, the page path and, for a view that uses TenantMixin, the tenant. Both places that hold it are scoped by TenantMixin.get_state_key_prefix() (tenant:<id>, the id percent-encoded):

  • The state backend (memory or redis), under tenant:<id>:<session>_<page>....
  • The Django session, under liveview_tenant:<id>:<path> (plus the __private, _components and __sticky__ siblings): the saved public and private state that the HTTP fallback POST restores, and that a WebSocket or SSE reconnect restores when the view sets enable_state_snapshot = True.

A session that reaches two tenants on the same URL (a header or session resolver, a cookie shared across *.example.com, or set_tenant()) therefore holds separate entries per tenant. The signed state_snapshot token is bound to the tenant as well; a mount under another tenant ignores it and mounts fresh.

  • A view without TenantMixin is keyed exactly as before.
  • A tenant view whose tenant is unresolved (for example tenant_required = False on a page with no tenant yet) saves and loads no state and mints no token. It never falls back to the shared, unprefixed key.
  • Upgrading: the keys for tenant views changed, so the saved state those views held under the old keys is not found: a one-time reset. The first reconnect or HTTP fallback POST re-runs mount(), the first Back navigation mounts fresh, and the next mount ships a new token. Views that do not use TenantMixin are not affected.

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.