Multi-Tenant Applications
djust provides comprehensive multi-tenant support for building SaaS applications with complete 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 state backends, presence, 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, state backends, 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
- State backend isolation -- Tenant-aware Redis and memory backends
- 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):
# 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):
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):
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 in dispatch() / get() / post(), before
mount() runs. 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)
State Backend Isolation
# settings.py
DJUST_STATE_BACKEND = 'djust.tenants.backends.TenantAwareRedisBackend'
# or
DJUST_STATE_BACKEND = 'djust.tenants.backends.TenantAwareMemoryBackend'
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:
class TenantPermissionMixin:
def dispatch(self, request, *args, **kwargs):
if 'tenant_slug' in kwargs:
if kwargs['tenant_slug'] != request.tenant.id:
raise PermissionDenied("Access denied")
return super().dispatch(request, *args, **kwargs)
Testing
from djust.tenants import set_current_tenant
from djust.tenants.resolvers import TenantInfo
from django.test import RequestFactory
def test_tenant_scoped_query(db):
# Set the thread-local tenant, then assert tenant-scoped filtering.
set_current_tenant(TenantInfo(tenant_id='acme'))
try:
projects = Project.objects.filter(tenant_id='acme')
assert projects.count() == 2
finally:
set_current_tenant(None) # clear thread-local between tests
def test_view_with_tenant():
request = RequestFactory().get('/dashboard/')
request.tenant = TenantInfo(tenant_id='test', name='Test Org')
view = DashboardView()
view.setup(request)
view.tenant = request.tenant # mixin populates this from request.tenant in dispatch
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.