---
title: "Multi-Tenant Applications"
slug: multi-tenant
section: guides
order: 7
level: advanced
description: "Build SaaS apps with TenantMixin, flexible resolution strategies, and tenant-scoped querysets and presence"
---

# Multi-Tenant Applications

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](https://github.com/django-tenants/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](https://github.com/djust-org/djust/issues/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](migrating-from-django-tenants.md)** (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:

```python
# 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

```python
# 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

```python
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

```python
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

```python
# 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

```python
# 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

```python
# X-Tenant-ID: acme -> tenant_id: "acme"
DJUST_CONFIG = {
    'TENANT_RESOLVER': 'header',
    'TENANT_HEADER': 'X-Tenant-ID',
    'TENANT_DEFAULT': 'public',
}
```

### Session / JWT

```python
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

```python
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)

```python
# 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.

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

### TenantScopedMixin

Extends TenantMixin with scoped querysets:

```python
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:

```python
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()`):

```python
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`:

```python
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](https://github.com/djust-org/djust/issues/2973)). Before 1.2.1,
listing `PresenceMixin` first dropped the prefix.

```python
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.

```python
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:

```python
# 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](https://github.com/djust-org/djust/issues/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:

```html
<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:

```python
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

```python
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.
