Multi-tenancy¶
Meta.tenant_field scopes every default-manager query of a model to the tenants active for it: a filter
on the tenant column, added the way soft delete adds its filter on the deletion column.
A tenant's tables can instead live in a schema of their own — see
Schema per tenant.
class Order(Model):
company_id = fields.IntField(db_index=True)
...
class Meta:
tenant_field = "company_id"
from hare.models.tenancy.tenancy import Tenancy
with Tenancy.scope(company.id):
await Order.objects.filter(status="pending") # WHERE company_id = <company.id> AND status = 'pending'
await Order.objects.create(total=10) # company_id filled from the scope - see below
tenant_field can also name a forward ForeignKeyField/OneToOneField — tenant_field = "company" for
company = fields.ForeignKeyField("models.Company") means its key column, company_id, and
Order._meta.tenant_field reads "company_id".
Tenancy.scope(tenant)¶
from hare.models.tenancy.tenancy import Tenancy
with Tenancy.scope(company.id): # one value, for every model
...
with Tenancy.scope(Tenancy.any_of("msk", "kzn")): # several values, for every model
...
with Tenancy.scope(Tenancy.ALL): # every tenant
...
with Tenancy.scope({Order: Tenancy.any_of("msk", "kzn"), SalesSummary: 2, Faq: Tenancy.ALL}):
... # each model's own tenants
tenant |
Rows a query sees | A row written without a tenant | A row written with a tenant |
|---|---|---|---|
| A value | WHERE <tenant_field> = value |
gets the value | must be that value |
Tenancy.any_of(*values) |
WHERE <tenant_field> IN (values) |
QueryError — name it |
must be one of the values |
Tenancy.ALL |
every tenant's, no filter | QueryError — name it |
any value |
| A dict by model | each model by its own entry — one of the three above | by the model's entry | by the model's entry |
A value, Tenancy.any_of(...) and Tenancy.ALL given directly apply to every model alike:
Meta.tenant_field only names which column a given model uses (company_id on one model, org_id on
another), and the scope is compared with whatever column each model names. That fits models split by the
same tenants. Models split by different fields with different sets of values (orders by store —
"msk"/"kzn", sales summaries by store_id — 1/2, FAQ entries by group) take a dict. Its
keys are model classes or their "app.Model" labels ({"shop.Order": "msk"}), looked up in the current
context.
Tenancy.any_of() needs at least one value and takes no None — QueryError (a ValueError)
otherwise: no value at all neither means every tenant nor no rows. A value repeated is kept once, and
Tenancy.any_of(value) with one value is the same scope as value itself. A list, a tuple or a set
given as the scope (or as a model's entry) raises QueryError — several values are always spelled
Tenancy.any_of(*values).
A scope by model¶
The keys of the dict are model classes with Meta.tenant_field or
Meta.tenant_schema; anything else, and None as an entry, raises QueryError
when the scope is entered. A model takes:
- the entry of its own class;
- else the entry of its nearest base class with the same
Meta.tenant_field(aMeta.tenant_schemamodel without one: withMeta.tenant_schema) — one entry for an abstract base covers every model built on it, and a model's own entry overrides it; - else it has no scope: it behaves exactly as outside any
Tenancy.scope()— a query raisesQueryError(see below), a write trusts the tenant it names. Give a modelTenancy.ALLto leave it unlimited on purpose.
A swapped model is looked up by the class that replaces it, like any other model — name that class, or a base class both share.
Every part of one query reads its own model's entry: the query's own filter, a JOIN to a related model,
the second query of prefetch_related(), a many-to-many's through table and target.
with Tenancy.scope({Order: Tenancy.any_of("msk", "kzn"), Customer: "north"}):
# orders of two stores, joined to the customers of the north only
await Order.objects.filter(customer__name__startswith="A")
A Meta.tenant_schema model's entry is one tenant value: its tables are read in one tenant's schema, so
Tenancy.any_of(...) of several values and Tenancy.ALL raise QueryError when the scope is entered. A
connection works in the schema of the tenant the scope gives its Meta.tenant_schema models — see
Queries.
Registering or unregistering live models updates the connection's tenant schema selection and the
table tenants used when a new row-level-security transaction begins, even inside an existing scope.
A transaction already in progress keeps the tenant settings it began with.
Reusing a model scope in another HareContext selects that context's models, including when both
contexts use the same connection alias.
with Tenancy.scope({Invoice: "acme", Order: Tenancy.any_of("msk", "kzn"), Faq: Tenancy.ALL}):
await Invoice.objects.count() # tenant_acme.invoice
await Order.objects.count() # public.order, two stores
Tenancy.get_scope(model) gives a model's scope in the current task: the value, the
Tenancy.any_of(...) object (its values is the tuple), Tenancy.ALL, or None when no scope is
active or the active one doesn't name the model. Use it instead of reading Tenancy.current.get(), which
holds the whole scope — for a dict, an object whose get_for_model(model) answers the same question.
Tenancy.get_values(scope) turns a model's scope into the tuple of values its rows are limited to — one
value, the any_of values — or None when they aren't limited (no scope, or Tenancy.ALL).
Lifetime¶
Nests correctly: a nested Tenancy.scope(...) replaces the outer scope for the queries it wraps —
the two are not merged, so inside a nested dict a model the dict leaves out has no scope — and the outer
block's scope is back in effect once the inner block exits. Each asyncio task has its own scope.
A queryset runs for the scope active when it is awaited, and everything it runs then is scoped by
that one scope: its own filter, every JOIN it makes, .values()/.values_list()/ .count() built from
it, its use as a subquery (field__in=queryset, Subquery(...), Exists(...)) and the second query of
.prefetch_related().
A scope value is compared with a row's tenant after the tenant field's own conversion, so for a
UUIDField tenant Tenancy.scope(uuid_value) and Tenancy.scope(str(uuid_value)) are the same tenant
on every database.
Tenancy.set(tenant)/Tenancy.reset(token) are the lower-level primitives Tenancy.scope() itself is
built on, for a case where a single with block can't span the scope's intended lifetime (e.g. across an
await boundary spanning multiple callback invocations) — the same shape as the
QueryTags.set()/QueryTags.reset() pair of SQL comment tags.
No active scope¶
Querying a tenant-scoped model that has no scope — no Tenancy.scope() is active, or the active one is a
dict that doesn't name the model — raises QueryError immediately — deliberately, instead of silently
filtering by tenant_field = None (which would just match zero rows and look like "no data" rather than
"you forgot to scope this call"). If you hit this, wrap the call in Tenancy.scope(...), or add the
model to the dict.
Every tenant: all_tenants()¶
Returns a QuerySet that bypasses the automatic filter entirely — for admin tooling, background jobs,
and migrations that deliberately need to see across every tenant. Tenant-scoped models the query reaches
through relations (a relation filter, Count("docs"), values("docs__title"), ...) span every tenant
too. On a model with no Meta.tenant_field of its own that is all it does — the way to run a relation
query from a shared model with no tenant active:
await Tag.objects.all_tenants().filter(docs__isnull=True) # tags no tenant links to
await Tag.objects.all_tenants().annotate(uses=Count("docs")) # links of every tenant
await tag.docs.clear(all_tenants=True) # every tenant's links of this tag
Like include_deleted(), it builds the queryset without the tenant condition
rather than removing one already applied.
.all_tenants().delete() is a deliberate cross-tenant delete: it deletes every matched row whatever the
active scope, both when it runs as one statement and when it deletes row by row (a model other models
point at, whose cascade runs per row).
Write-path guards¶
Every write is checked against the scope of the model it writes (Tenancy.get_scope(model)): "the
scope's value" below is the one value of that scope, and "inside the scope" means that value, one of the
Tenancy.any_of(...) values, or anything under Tenancy.ALL.
create()andsave()of a new row filltenant_fieldwith the scope's value, and raiseQueryErrorwhen the row names a tenant outside the scope. UnderTenancy.any_of(...)andTenancy.ALLthere is no one value to fill in: the row has to name its tenant,QueryErrorotherwise. So doget_or_create()andupdate_or_create()when they create — pass the tenant indefaults/create_defaults. Whentenant_fieldis aForeignKeyField's own column (tenant_field = "company_id"forcompany = fields.ForeignKeyField(...)), passing the relation (create(company=company)) counts as giving the tenant: accepted with no scope, checked against the scope inside one.save()of an existing row writes only a row whose tenant — the one on the instance and the one stored in the database — is inside the scope: theUPDATEcarries the scope in itsWHERE, so an instance built by hand around another tenant's primary key writes nothing. Changing the tenant of an instance and saving it moves the row, to a tenant inside the scope only.delete()andrestore()of an instance are checked the same way.- A forward FK/O2O can't point at a row its own model's scope doesn't show.
create(),save(),.update(...),bulk_create()andbulk_update()read the tenant of every tenant-scoped row a written relation names (one query per relation and statement) and raiseQueryErrorwhen it is outside the scope of the related model — from a tenant-scoped model and from a shared one alike, as M2Madd()rejects such a row. With one value for every model that is "the related row is the active tenant's". A related model with no scope or withTenancy.ALLisn't checked, nor are.all_tenants().update(...),merge()andinsert_from()into an.all_tenants()target, and writes with no scope at all (seed data, trusted like an explicittenant_fieldthere). The writes of objects —create(),bulk_create(),bulk_update()and the rest — are checked under the active scope even through.all_tenants(), which widens the rows a query reads, not the rows an object points at. A key naming no row is left to the database — except a key into a table of aTenantConditionpolicy on a connection withtenant_row_level_security: the policy hides the other tenants' rows from the check while the database's foreign key still accepts them, so a key whose row the scope doesn't show raisesQueryError. On ClickHouse, which keeps no key unique, two tenants' rows may share a key: a key passes when one of its rows is in the scope, M2Madd()alike, and a delete under a scope cascades to the rows of the scope only. - M2M
add()fills a through model's owntenant_fieldwith the value of the through model's scope, and needs it inthrough_defaultsunder a scope of several values (see AModelasthrough). .update(<tenant_field>=value)moves rows inside the scope only.valuehas to be a plain value inside the model's scope — under one value that is the value the rows already have, underTenancy.any_of("msk", "kzn")an order moves between the two stores. A value outside the scope,None, an expression, and any such update with no scope or on.all_tenants()raiseQueryError—Nonefor a tenant field that isn'tnull=Trueraises the field's ownValidationErrorfirst..update(company=...)for a tenant relation follows the same rule as.update(company_id=...).bulk_update()checks every object's tenant explicitly.bulk_update()matches rows purely by their own PK (likeModel.save()) and deliberately does not go through the same auto-filtering path.filter()/.all()do — so before issuing anything, it verifies the model has a scope and that every object being updated belongs to a tenant inside it, raisingQueryErrornaming the mismatched PKs otherwise. Without this explicit check, a caller that somehow obtained another tenant's primary keys could silently overwrite that tenant's rows.bulk_create()requires a scope. With none for the model it raisesQueryError, even when every instance already carries atenant_fieldvalue (unlikecreate(), which trusts an explicit tenant outside a scope). Inside a scope, an instance with notenant_fieldvalue yet gets the scope's value —QueryErrorunder several values orTenancy.ALL— and an instance carrying a tenant outside the scope makes the whole call raiseQueryErrornaming the mismatched PKs.bulk_create(update_fields=...)only overwrites rows inside the scope.ON CONFLICTmatches the table's physical unique constraint regardless of tenant, so theDO UPDATEcarriesWHERE <tenant column> = <value>(IN (<values>)for several): a conflicting row of a tenant outside the scope is left untouched. The objects are then not marked saved (the call can't tell which of them were written) unlessreturning=Truewithon_conflict=[...]matches the written rows back to them; an object whose row was skipped gets nothing fromRETURNING. Not applied underTenancy.ALLand.all_tenants().QuerySet.delete()and.update()reach the rows inside the scope, like any query. A cascade from a deleted row follows its foreign keys to related rows of every tenant, andrestore(cascade=True)brings back what that delete took with it — see soft delete.
Relations to a tenant-scoped model¶
A forward FK/O2O from any model to a tenant-scoped one sees only a target inside the related model's own
scope, like a soft-deleted target: note.doc, select_related(), values("doc__title") give None for
a target of another tenant, and doc__isnull=True (doc=None, doc__not_isnull=False) matches such a
row, doc__isnull=False doesn't. A null check on the target's own columns agrees: a hidden target has no
visible columns, so doc__title__isnull=True (doc__title=None) matches the same rows as
doc__isnull=True, in filter() and exclude() alike. The key column (doc_id__isnull, doc_id=...)
keeps comparing the stored value. A relation read or a relation-level null check of this type needs an
active scope (or .all_tenants() on the filtered queryset, which drops the tenant part of the check) —
just like values("doc__title"). The same applies to a target a custom Meta.manager filters out.
A many-to-many relation to a tenant-scoped model is scoped the same way, from either side:
Tag.objects.filter(docs=doc_pk), docs__in, docs__not, docs__not_in, their exclude() forms and
docs__isnull count only links to rows of the active tenant (and not soft-deleted, and not hidden by a
custom Meta.manager), exactly like docs__title=... does. So does every path through the relation —
docs__id__isnull, docs__title=..., values("docs__id"), order_by("docs__title"),
F("docs__title"), Count("docs"): a link to a hidden row adds no row and no NULL of its own.
tag.docs.clear() and tag.docs.set(...) remove only such links too — a link from the same tag to
another tenant's row stays; add()/remove() reject a related object of another tenant outright, and
add() also rejects a row the database shows as soft-deleted or hidden by the related model's
Meta.manager (a stale or hand-built instance included).
Whether a shared row is in use depends on the active tenant
For a model with no Meta.tenant_field (a tag shared by every tenant), "has links" means "has links to
rows the active tenant sees". Inside Tenancy.scope(1),
Tag.objects.filter(docs__isnull=True).delete() also deletes a tag that only tenant 2's documents use.
Clean up shared rows with no tenant active, through Tag.objects.all_tenants() (see above), so every
tenant's links count.
Routers¶
A router choosing a database by tenant (sharding) runs
with the scope its query runs for — the one active when the queryset is awaited, kept for the queries it
runs then (prefetch_related(), a subquery) — so the connection it picks holds the tenants the query
filters by. Tenancy.get_scope(model) inside db_for_read(model)/db_for_write(model) gives that
model's part of it: one value; a Tenancy.any_of(...) object, whose values the router has to map to one
database or refuse; Tenancy.ALL; or None. Tenancy.current.get() still reads the whole scope — the
value itself for a scope of one value.
class StoreRouter:
def db_for_read(self, model):
stores = Tenancy.get_values(Tenancy.get_scope(model))
if stores is None: # no scope, or every tenant
return None # the default connection
shards = {SHARD_BY_STORE[store] for store in stores}
if len(shards) > 1:
raise RuntimeError(f"{model.__name__}: stores {stores} are on different shards")
return shards.pop()
db_for_write = db_for_read
Pydantic input models¶
pydantic_model_creator() makes Meta.tenant_field optional in an input
model: left out, create() fills it with the scope's value. Under a scope of several values (or
Tenancy.ALL) the request has to carry it — it is accepted and checked against the scope like any
create() value, and leaving it out raises QueryError.
With Meta.soft_delete_field¶
A model can set both options at once — Manager.get_queryset() adds both filters independently, so a
model with both configured gets both applied on every default-manager query. The two escape hatches don't
imply each other, though: all_tenants() still respects soft_delete_field (soft-deleted rows stay
hidden unless you also call include_deleted()), and include_deleted() still respects tenant_field
(you still need an active scope, or all_tenants(), to see across tenants).
Query plan cache¶
The tenant filter of a query on the scoped model itself is an ordinary filter value, added the way
soft_delete_field's own deleted_at=None filter is, so the
query plan cache substitutes it per call like any other filter value,
and the tenant is no part of the shape key — unlike the zone name of the date-part lookups, which a cache
hit doesn't substitute. A scope of several values is an ordinary __in filter in the same place.
A query reaching a tenant-scoped model through a relation puts the tenant into the JOIN's ON condition.
Its plan keeps that scope's values apart from the query's own, and a query running on the plan binds the
scope active when it runs; a scope whose filter has another shape (another number of tenants under
Tenancy.any_of(...)) builds the query in full. The same holds for a model a custom Meta.manager
filters — see Query plan cache.
Unique and exclusion constraints¶
Neither is tenant-aware. A UniqueConstraint and PostgreSQL's own ExclusionConstraint (the standard
way to enforce "no two rows for the same resource can have overlapping ranges" — see
DateTimeRangeField) are both checked purely against the literal columns you name — there is no
automatic tenant scoping added underneath. Two rows with the same fields/expressions values under
different tenants will still collide unless you explicitly include the tenant column in the constraint
yourself (e.g.
ExclusionConstraint(expressions=[("company_id", "="), ("resource", "="), ("during", "&&")])).
Rollout¶
Unlike most opt-in Meta options, turning on tenant_field changes the behavior of every existing
query against that model — recommended rollout:
- Add
Meta.tenant_fieldto one model at a time, starting with something low-traffic. - Audit every existing call site against that model for a missing
Tenancy.scope()before relying on the auto-filter —all_tenants()is your bridge for code you haven't updated yet. - Only then move on to the next model.
The filter above is enforced in Python, at the Manager.get_queryset() layer — a raw SQL query or a bug
that bypasses the ORM's default manager isn't covered by it. On PostgreSQL the database itself can
enforce it too — see Row level security.
Row level security (PostgreSQL)¶
On PostgreSQL a Meta.tenant_field model can have the database keep each transaction to its tenants,
whatever SQL runs in it — a Policy with TenantCondition(), on a connection with
tenant_row_level_security:
from hare.ddl import Policy, RowLevelSecurity, TenantCondition
class Order(Model):
company_id = fields.IntField(db_index=True)
...
class Meta:
tenant_field = "company_id"
row_level_security = RowLevelSecurity.FORCED
policies = [Policy(name="order_tenant", using=TenantCondition())]
config = {**HARE_CONFIG, "connections": {"default": "postgresql://app@localhost/app?tenant_row_level_security=true"}}
- The transaction's tenants. Every transaction on the connection sets its tenants right after
BEGIN, from theTenancy.scope()active when it began —SELECT set_config('hare.tenant', ..., true), a JSON array of the tenant values as text: one value,Tenancy.any_of(...)'s values, or every tenant underTenancy.ALL. Under a scope by model it is a JSON object of each table's tenants, by the table's name (after itsMeta.schema, when it has one:{"order": ["msk", "kzn"], "shop.faq": "*"}) — every table of the connection's models with aTenantConditionpolicy the scope names. The setting is local to the transaction — it ends with it, and a pooled connection never carries it into the next one. - The policy.
TenantCondition()is written as "the row's tenant column is one of the transaction's tenants" — of its own table's entry under a scope by model (every row underTenancy.ALL); as theusingof anALLpolicy it also checks every row written. The tenants are read once for a statement, not for each row. - No tenant, no rows. A transaction begun under no scope sets no tenant, and a scope by model gives none to a table of a model it doesn't name — the policy lets the transaction see and write none of those rows, raw SQL included.
- Queries through the ORM of such a model run inside a transaction that set its tenants — outside a
transaction, in one begun with no tenant, or under a scope by model not naming the model, they raise
QueryErrorbefore any SQL instead of returning nothing. A query under another scope than the one the transaction began in raisesQueryErrortoo: the transaction's setting can't follow it. - A model with a
TenantConditionpolicy on a connection withouttenant_row_level_securityraisesConfigurationErroron its first query; one withoutMeta.tenant_fieldwhen it is declared. - Roles. PostgreSQL doesn't apply row level security to a superuser or a role with
BYPASSRLS, and applies it to the table's owner only withRowLevelSecurity.FORCED— connect as a role it applies to. - The Python-side filter stays as it is — the policy is a second check under it, not a replacement.
- Changing
Meta.tenant_fielddoesn't change the policy's text by itself — rename the policy in the same change so the migration drops and creates it.
SQLite and ClickHouse have no row level security: Policy there raises UnSupportedError before any
SQL.