Row-level multi-tenancy¶
dorm offers two flavours of multi-tenancy:
| Flavour | Module | Isolation | Cost |
|---|---|---|---|
| Schema-level (PG) | dorm.contrib.tenants |
search_path per tenant |
one migration per tenant |
| Row-level (any backend) | dorm.contrib.tenants_row |
tenant_id column + auto filter |
one migration total |
This page covers the second. Added in 4.0.
Quick trade-off¶
| If… | Use… |
|---|---|
| You have 5 large enterprise tenants | schema-level |
| You have 5000 small tenants | row-level |
| Compliance demands physical isolation | schema-level |
| Same schema is fine; isolation in code | row-level |
| Backend is MySQL/SQLite/DuckDB | row-level (schema-level is PG-only) |
The contract¶
import dorm
from dorm.contrib.tenants_row import TenantModel, current_tenant
class Note(TenantModel):
title = dorm.CharField(max_length=200)
TenantModel adds:
- A
tenant_idcolumn (CharField(max_length=64, db_index=True)). Override in subclasses forUUIDField/IntegerField/ etc. - An
objectsmanager that automatically filters every queryset bytenant_id = <active tenant>. - An
unscopedmanager — escape hatch with no filter. save()/asave()auto-filltenant_idfrom the active context.
Activating a tenant¶
Wrap each handler / job in:
from dorm.contrib.tenants_row import current_tenant
with current_tenant(request.user.tenant_id):
Note.objects.create(title="hi") # tenant_id auto-filled
notes = list(Note.objects.all()) # auto-filtered
current_tenant() uses contextvars — per-task in asyncio,
per-thread in sync. No leak between requests.
Nested:
No active tenant → error¶
Calling the manager outside current_tenant(...) raises:
>>> list(Note.objects.all())
NoActiveTenantError: No active tenant — wrap the call in
`with current_tenant(<tenant_id>):` or use `Note.unscoped` for a
deliberate cross-tenant query.
By design. A silent fallback to "every tenant" would leak data between customers.
Escape hatch — unscoped¶
For reports, admin, cross-tenant dashboards:
all_notes = list(Note.unscoped.all())
note_count_by_tenant = (
Note.unscoped
.values("tenant_id")
.annotate(n=dorm.Count("id"))
)
unscoped is deliberately verbose — every use surfaces in code
review.
FastAPI middleware¶
from fastapi import Request
from dorm.contrib.tenants_row import current_tenant
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
tenant = request.headers.get("X-Tenant-ID")
if tenant is None:
return JSONResponse({"detail": "missing tenant"}, status_code=400)
with current_tenant(tenant):
return await call_next(request)
Any ORM query inside the handler picks up the tenant automatically.
Worker job (Celery / arq)¶
@app.task
def send_digest(tenant_id: str):
with current_tenant(tenant_id):
notes = list(Note.objects.filter(...))
# ...
Override the column name¶
If your domain uses org_id instead of tenant_id:
from dorm.contrib.tenants_row import TenantManager, TenantModel
class OrgScopedManager(TenantManager):
tenant_field = "org_id"
class Note(TenantModel):
org_id = dorm.CharField(max_length=64, db_index=True)
title = dorm.CharField(max_length=200)
objects = OrgScopedManager()
(The inherited tenant_id can stay or be redeclared as
IntegerField if your org_id is integer — override the field and
the manager.)
Compose with sharding¶
HashShardRouter (3.4+) and TenantModel compose well: the shard
key is usually the tenant id.
from dorm.contrib.sharding import HashShardRouter, with_shard_key
DATABASE_ROUTERS = [
HashShardRouter(num_shards=4, shard_models={Note}),
]
with current_tenant(tenant_id), with_shard_key(tenant_id):
Note.objects.create(title="hi") # tenant filter + shard routing
Caveats¶
- Unique constraints:
UNIQUE(name)is not per-tenant. UseUNIQUE(tenant_id, name)or a partial indexUNIQUE(name) WHERE tenant_id = .... - Cross-tenant foreign keys: nothing stops
Note.author_idfrom pointing at a User in another tenant. Validate at the code path or add a CHECK constraint. - Per-tenant backups: impossible with row-level — every tenant shares the tables. For backup-per-tenant use schema-level.
- Dropping a tenant: with row-level it's
Note.unscoped.filter( tenant_id=X).delete()— costly on large tables. Consider partitioning bytenant_idif churn is high.
More¶
- Schema-level tenants —
dorm.contrib.tenants - Sharding — combining with multi-tenancy
- When to use what — schema-level vs row-level