references/multi-tenancy.md
Multi-tenancy
Pick the model before the first collection exists. Migrating tenant data between models after launch means downtime and a bespoke script per app.
The three models
Shared collections with tenantId
Every document carries a tenant identifier; every query filters on it.
{ _id: ..., tenantId: ObjectId("..."), schemaVersion: 1, name: "Hilux", ... }
Choose this by default. It scales to an unbounded number of tenants, needs one index per access pattern rather than one per tenant, and keeps backup, restore, and sharding to a single operation.
The cost is that isolation becomes an application invariant rather than a database boundary. One forgotten filter is a cross-tenant data leak. That is a real risk and it is manageable, but only if you make it structural (see Enforcement below).
Database per tenant
Each tenant gets a database, with identical collections inside.
Choose this when a specific customer contract demands physical separation, when regulation requires per-tenant encryption keys or residency, or when per-tenant restore is a product feature.
Understand the ceiling before committing: every database multiplies index and collection count across the cluster. Clusters begin to struggle in the low thousands of databases, and index count is usually the binding constraint. The model that feels safest is the one that stops scaling first.
Connection pooling also degrades, since a pool per database defeats the point of pooling.
Collection per tenant
One collection per tenant inside a shared database. Usually the worst option: it carries the index multiplication of database-per-tenant without the isolation benefit, and it makes any cross-tenant query a loop.
Reasonable only for a small, fixed tenant count that will not grow.
Hybrid
Shared collections for the long tail, dedicated databases for the few large or regulated tenants. A tenants registry maps tenant to its placement, and the data-access layer routes accordingly.
This is the pragmatic end state for most SaaS products, and worth designing for early even if every tenant starts shared. Routing through a resolver from day one costs almost nothing; retrofitting it later touches every query.
Keep tenantId on documents even in database-per-tenant. It costs a few bytes, and it is what makes cross-tenant analytics, data migration between models, and exports possible later.
Enforcement
This is the part that matters. Everything above is a tradeoff; this is a control.
Never rely on developers remembering to filter. The failure is silent, the tests pass, and the bug is discovered by a customer seeing another customer's data.
Make the tenant filter structural: one data-access layer that injects it, and no path to the driver that bypasses that layer.
class TenantRepository:
"""Every query is scoped. There is no method that returns an unscoped cursor."""
def __init__(self, db, tenant_id: ObjectId):
if tenant_id is None:
raise ValueError("tenant_id is required")
self._db = db
self._tenant_id = tenant_id
def _scope(self, filt: dict | None = None) -> dict:
return {**(filt or {}), "tenantId": self._tenant_id}
async def find(self, collection: str, filt=None, **kw):
return self._db[collection].find(self._scope(filt), **kw)
async def insert_one(self, collection: str, doc: dict):
return await self._db[collection].insert_one(
{**doc, "tenantId": self._tenant_id}
)
Supporting rules:
- The tenant comes from the authenticated session, never from a request body
or a query parameter. A ?tenantId= that the client controls is an authorization bypass with extra steps.
- Cross-tenant access (admin tooling, analytics) goes through a separate,
explicitly named path such as UnscopedRepository, so it is greppable in review and rare in the codebase.
- Ban raw collection handles outside the data layer. A lint rule or an import
boundary is worth the friction.
- Updates and deletes need the filter as much as reads do. A
delete_many
missing the tenant scope is the worst possible version of this bug.
Indexing for tenancy
Every index starts with tenantId. No exceptions in shared collections.
{ tenantId: 1, status: 1, createdAt: -1 }
By the ESR rule the tenant is an equality match, so it belongs first anyway. This also keeps each tenant's keys contiguous in the index, which keeps the hot tenant's working set compact.
Unique constraints must be scoped to the tenant, or tenant B cannot register an email that tenant A already used:
db.users.createIndex({ tenantId: 1, email: 1 }, { unique: true })
A global unique index on email in a shared-collection design is a bug that surfaces as a mysterious signup failure for your second customer.
Sharding
If the collection will be sharded, the shard key should usually begin with tenantId, so a tenant's data is co-located and ordinary queries hit one shard instead of scattering.
The risk is a single enormous tenant creating a jumbo chunk that cannot be split. Compound the key with something high-cardinality to keep chunks splittable:
{ tenantId: 1, _id: 1 }
Move a tenant that outgrows a shard to its own database. This is the moment the hybrid model earns its keep.
Noisy neighbours
In a shared cluster, one tenant's expensive query degrades everyone. Mitigations, cheapest first:
- Cap
limiton every list endpoint and paginate by cursor. - Set per-operation
maxTimeMS, so a pathological query is killed rather than
running for minutes.
- Apply per-tenant rate limits at the API layer.
- Watch the slow query log by tenant. The distribution is usually very uneven,
and the top tenant is usually the one about to page you.
Deletion and export
Contracts and privacy law both eventually require "delete everything for this tenant" and "export everything for this tenant".
In database-per-tenant this is one command. In shared collections it is a script across every collection, which is straightforward only if every collection carries tenantId. Enforce that at creation time, including on collections that feel internal such as audit logs and job records. Those are the ones that get missed, and they are the ones with the most sensitive content.
Write the export and delete paths early and test them. Discovering at contract signature that three collections have no tenant field is an unpleasant week.
Testing isolation
One test, in every project, that fails loudly:
async def test_tenant_cannot_read_another_tenants_data(client):
a = await make_tenant_with_vehicle(name="A car")
b = await make_tenant_with_vehicle(name="B car")
r = await client.get("/v1/vehicles", headers=b.headers)
names = [v["name"] for v in r.json()["data"]]
assert "A car" not in names
# direct id access must 404, not 403: existence itself is information
r = await client.get(f"/v1/vehicles/{a.vehicle_id}", headers=b.headers)
assert r.status_code == 404
Return 404 rather than 403 for another tenant's object. A 403 confirms the id exists, which is a small information leak that enables enumeration.
Add this test for every resource type, not just one. It is the cheapest insurance in the whole system.