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.
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 (
memoryorredis), undertenant:<id>:<session>_<page>.... - The Django session, under
liveview_tenant:<id>:<path>(plus the__private,_componentsand__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 setsenable_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
TenantMixinis keyed exactly as before. - A tenant view whose tenant is unresolved (for example
tenant_required = Falseon 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 useTenantMixinare 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
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.