Scoping Tenant Data with contextvars¶
A multi-tenant service has to know, at every database query and cache lookup, which tenant the current request belongs to — and must never answer with another tenant's data. In asyncio, the place to keep that is a context variable, and the place to enforce it is as close to the data as possible. Measured on Python 3.14 with 1,000 concurrent requests across five tenants, each awaiting once after recording its tenant: a module-level variable held the wrong tenant by the time 800 requests read it back; a ContextVar held the right one in all 1,000. With Postgres row-level security keyed on a session setting, a superuser connection ignored the policy and saw all 500 rows where the application role saw its tenant's 100. Through a two-connection psycopg pool, setting the tenant at session level leaked: 19 of 200 requests — the ones that set no tenant — returned 1,900 rows belonging to whichever tenant last used the connection. Setting it transaction-locally leaked nothing, and so did asyncpg's pool even at session level, because it runs RESET ALL when a connection is released. This guide builds tenant scoping that holds under concurrency and connection pooling.
Prerequisites¶
- Python 3.11+, an async web framework, Postgres with asyncpg or psycopg 3.
- Context variable basics, from propagating request IDs with contextvars.
- The topic overview, Context Variables & Request Context.
1. Keep the tenant in a ContextVar, never a global¶
A request handler that stores the tenant in a module-level variable works in tests — one request at a time — and fails as soon as requests interleave at an await:
CURRENT = {"tenant": None} # shared by every request in the process
async def handler(request):
CURRENT["tenant"] = request.state.tenant
await load_user(request) # other requests run here and overwrite it
return await list_documents(CURRENT["tenant"])
Measured with 1,000 concurrent requests over five tenants: after one await, 800 of them read back a tenant other than their own. A context variable is per task, so each request's value survives its own awaits:
current_tenant: contextvars.ContextVar[str | None] = contextvars.ContextVar("tenant", default=None)
class TenantMiddleware:
async def __call__(self, scope, receive, send):
tenant = resolve_tenant(scope) # from the host, token or header
token = current_tenant.set(tenant)
try:
await self.app(scope, receive, send)
finally:
current_tenant.reset(token)
Measured: 0 of 1,000 requests saw another tenant. Set the variable once, at the edge — middleware or a dependency — from an authenticated source, and reset it with the token when the request ends.
Verify: a concurrency test with many tenants and an await between setting and reading the tenant finds no mismatches.
2. Fail closed when no tenant is set¶
Code that runs without a tenant — a background job, a health check, a test that forgot the middleware — must not default to "all tenants". Make the data layer refuse to run tenant-scoped queries without one:
class TenantMissing(RuntimeError):
pass
def require_tenant() -> str:
tenant = current_tenant.get()
if tenant is None:
raise TenantMissing("tenant-scoped query outside a tenant context")
return tenant
async def list_documents(conn) -> list[dict]:
tenant = require_tenant()
return await conn.fetch("SELECT * FROM docs WHERE tenant = $1", tenant)
Every query in the repository layer calls require_tenant() rather than receiving the tenant as an optional argument that a caller might leave out. Code that legitimately spans tenants — migrations, admin reports — uses separate functions with names that say so. Background jobs triggered by a request should carry the tenant deliberately, by passing it or by starting the task in a prepared context, as shown in passing an explicit context to create_task.
Verify: calling a tenant-scoped query with no tenant set raises instead of returning data.
3. Enforce it in the database with row-level security¶
A filter in application code can be forgotten in one query. Postgres row-level security applies a predicate to every query on the table, using a setting that the application provides per request:
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON docs
USING (tenant = current_setting('app.tenant', true));
GRANT SELECT ON docs TO app_user;
Measured with app.tenant set to t1: connected as app_user, a SELECT count(*) returned 100; connected as the postgres superuser, it returned all 500. Superusers and roles with BYPASSRLS ignore policies, and table owners do too unless FORCE ROW LEVEL SECURITY is set — so the application must connect as an ordinary role that owns nothing, or the policy is decoration. current_setting('app.tenant', true) returns NULL when the setting is absent, and tenant = NULL matches no rows, which makes the policy fail closed as well.
Verify: the application's database role returns only the current tenant's rows, and is neither a superuser nor the table owner.
4. Set the tenant per transaction, not per connection¶
Row-level security moves the problem into the connection pool. The tenant setting lives on the database connection, and pooled connections are reused by other requests. A session-level setting outlives the request that made it:
# leaks: the setting stays on the connection after it returns to the pool
await conn.execute("SELECT set_config('app.tenant', %s, false)", (tenant,))
Measured with a two-connection psycopg 3.3 pool and 200 requests, one in ten of which set no tenant: 19 of those 20 requests ran on a connection still carrying the previous request's tenant and returned 1,900 rows that were not theirs. Set it transaction-locally instead, so it disappears at COMMIT or ROLLBACK whatever happens next:
async with pool.connection() as conn, conn.transaction():
await conn.execute("SELECT set_config('app.tenant', %s, true)", (require_tenant(),))
cur = await conn.execute("SELECT * FROM docs")
Measured: 0 leaked rows across the same 200 requests. asyncpg's pool also showed no leak with session-level settings, because it runs RESET ALL when a connection is released — but relying on that ties correctness to one driver's default, and behind PgBouncer in transaction mode a session setting can land on another client's server connection regardless. SET LOCAL semantics, via set_config(..., true) inside the transaction, are correct everywhere.
Verify: a test that alternates tenants and tenant-less requests over a pool of one or two connections never returns rows from the wrong tenant.
5. Put the tenant into every cache key and log line¶
Databases are not the only place tenant data is stored. Caches keyed only by object ID or URL serve one tenant's data to another; logs without the tenant make incidents impossible to scope. Build both from the context variable so that no caller can forget:
def cache_key(*parts: str) -> str:
return ":".join((require_tenant(), *parts)) # "t3:report:2025-10"
class TenantLogFilter(logging.Filter):
def filter(self, record):
record.tenant = current_tenant.get() or "-"
return True
A key function that calls require_tenant() fails in exactly the code paths where a tenant-less key would have been shared. The same discipline applies to rate limits and quotas — per tenant, not per process — as in per-tenant rate limits in async services, and to background work, which should record the tenant it acts for at the moment it is created.
Verify: every cache key and log line written during a request includes its tenant.
Verification¶
Tenant data is scoped correctly when:
- The tenant lives in a
ContextVar, set and reset by middleware from an authenticated source. - Tenant-scoped code fails closed when no tenant is set.
- Row-level security runs as an unprivileged role, with the tenant set transaction-locally.
- Cache keys, logs and quotas include the tenant, derived from the same variable.
Diagnostic Hook: add a query comment or application_name containing the tenant, and periodically compare each query's tenant with the tenant column of the rows it returned in a sampled audit. A mismatch rate above zero is a leak in progress; the tests above show the two places it most often comes from — a global in application code and a session setting on a pooled connection.
Pitfalls & edge cases¶
- A module-level "current tenant". Measured: wrong in 800 of 1,000 concurrent requests.
- RLS with a superuser or table-owner role. Measured: the policy was ignored.
- Session-level settings on pooled connections. Measured: 1,900 rows leaked with psycopg.
- Relying on a driver's reset behaviour. asyncpg resets; others and PgBouncer may not.
Frequently Asked Questions¶
How do I store the current tenant in an asyncio app?
In a contextvars.ContextVar set by middleware for each request and reset at the end. A module-level variable held the wrong tenant in 800 of 1,000 concurrent requests in testing; the ContextVar in none.
Is Postgres row-level security enough for tenant isolation?
Only for an unprivileged role: as the superuser, all 500 rows were visible despite the policy. Connect as a role that is not a superuser or the table owner.
Why did one tenant see another's data through a connection pool?
The tenant was set at session level and stayed on the pooled connection. With psycopg, 19 of 200 requests returned another tenant's rows; setting it with set_config(..., true) inside the transaction leaked none.
Does asyncpg's pool reset session settings?
Yes, it runs RESET ALL on release, so session settings did not leak in testing. Use transaction-local settings anyway, so correctness does not depend on the driver or a pooler.
Related¶
- Context Variables & Request Context — up to the topic overview.
- Measuring the cost of contextvars — why this is affordable everywhere.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.