Skip to content

Multi-Tenancy

Row-level isolation for a shared database serving multiple tenants — one tenants table's worth of customers in the same tables as everyone else's, rather than a database (or schema) per tenant. A model opts in just by declaring a tenant_id column; there's no mixin and nothing to remember to add to each query.

A different question from Feature Flags: tenancy answers "which tenant's data should this query see," not "is this capability switched on right now."

Setup

# main.py
from zeython import Application, TenancyServiceProvider

def resolve_tenant(request):
    # e.g. acme.example.com -> "acme"
    return request.url.hostname.split(".")[0]

app = Application()
app.register(TenancyServiceProvider(app, resolver=resolve_tenant))

resolver is a required argument — there is deliberately no default. How a request maps to a tenant is entirely app-specific (a subdomain, a header, the logged-in user's own tenant_id), and guessing wrong here is a cross-tenant data leak, not a cosmetic mistake:

resolver=lambda request: request.url.hostname.split(".")[0]      # subdomain
resolver=lambda request: request.headers.get("X-Tenant-ID")      # header
async def resolver(request):                                     # the logged-in user's tenant
    user = await current_user(request)
    return user.tenant_id if user else None

It may be sync or async, and should return None for a request that doesn't belong to a tenant (a public marketing page, a health check) — Model query methods apply no filter at all in that case, not "filter to tenant None".

Opting a model in

# app/Models/post.py
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped, mapped_column
from zeython import Model

class Post(Model):
    __tablename__ = "posts"

    tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
    title: Mapped[str] = mapped_column(String(255))

That's the whole opt-in. From here:

  • Post.find(id), Post.all(), Post.find_by(...), Post.paginate(...), and Post.search(...) (see Full-Text Search) all scope to the current request's resolved tenant automatically — including find() by ID, so a request can't read another tenant's row just by guessing (or enumerating) its ID.
  • Post.create(...) (and save() on a freshly-constructed Post()) assigns tenant_id from the current tenant automatically, unless it's already set (via direct attribute assignment on the instance before calling save()).
  • tenant_id is guarded from Post.create(**kwargs)/post.update(**kwargs) the same way id/created_at/etc. are — a caller can't reassign a row's tenant with Post.create(**await request.json()) or post.update(**data) (the pattern this framework's own docs teach everywhere else) just by including a "tenant_id" key in the body. Direct attribute assignment from trusted code (post.tenant_id = other_tenant_id; await post.save()) still works — only the bulk-kwargs path is guarded. See Mass-assignment protection.
  • A model with no tenant_id column is completely unaffected — registering TenancyServiceProvider changes nothing for it.

Outside a request

A background job, a scheduled task, or a script has no request for TenancyServiceProvider's middleware to resolve a tenant from — use as_tenant to scope a block of code explicitly:

from zeython.tenancy import as_tenant

with as_tenant(tenant.id):
    posts = await Post.all()   # only this tenant's rows

With no as_tenant block and no request, current_tenant_id() is None and queries are unscoped — the same "no filter, not a filter to nothing" behavior as a request whose resolver returned None. This is the deliberate default: a script that genuinely needs to operate across every tenant (a nightly report, a migration) shouldn't have to fight the framework to do it, and a script that forgot to scope itself is a bug in the script, the same as forgetting a WHERE clause would be without this feature at all.

Reading it directly

from zeython.tenancy import current_tenant_id

tenant_id = current_tenant_id()   # whatever the resolver returned, or None

What this isn't

  • Not database- or schema-per-tenant. Every tenant's rows live in the same tables. If you need hard physical isolation (a compliance requirement, wildly different tenant sizes), this isn't that — you'd be routing to a different Database/connection per tenant instead, which this feature doesn't help with.
  • No tenant management UI or provisioning. Creating a tenant, inviting users to it, billing — all yours to build; this is purely the query- and-assignment isolation mechanism.
  • Relationship eager-loading isn't tenant-scoped. include=("author",) on find/all/etc. fetches the related row via its own foreign key (selectinload()), never through the related model's own _base_select() — so if a foreign key on a tenant-scoped row can ever point at another tenant's row (a legitimately-fillable FK, or a bug elsewhere), eager-loading it returns that other tenant's data with no error. Keep a tenant-crossing foreign key from being set in the first place (validate it against the current tenant in the model's own creating()/updating() hook — see Model Events) rather than relying on the relationship load to catch it.