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

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](https://github.com/djust-org/djust/issues/2973)).

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