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 todjust.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_intervalpolling (typically every 1.5–5s),push_to_viewre-mounts across all connected sessions, presence updates, and@notify_on_savelistener re-mounts each re-enter the middleware. Without theTENANT_LIMIT_SET_CALLSstopgap (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_idfiltering 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_idcolumns, write a one-time data migration to copy schema-qualified rows into the shared schema with the righttenant_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_connectionsfor at leastreplicas × ASGI_THREAD_LIMITplus headroom for Celery/admin/migrations. The defaultmax_connections = 100is 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 --
TenantMixinprefixes 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',
}
Header
# 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_idmodel 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)
| Method | Description |
|---|---|
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 ownself.<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
TenantScopedMixinor manual filtering. - Index
tenant_idfields 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 beforeTenantMixinresolves the tenant. In the pre-mount sequence, auth (includingcheck_permissions()) is step 1 and tenant resolution is step 3, so on the WebSocket and SSE pathsself._tenantis stillNoneinside the hook. Resolve the tenant yourself there, as above. A hook that only readsself._tenantdenies 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
TenantScopedMixinfor all data-accessing views to prevent cross-tenant leakage. - Include
tenant_idin cache keys to prevent data bleed through caching. - Consider separate database connection pools per tenant for heavy workloads.
- Use
select_related/prefetch_relatedwith tenant-scoped querysets for performance.