ContextVar for Request-Scoped State
ContextVar for Request-Scoped State
Thesis
In concurrent Python, a global is a lie: the next await lets another task mutate it. contextvars.ContextVar copies values along with the task context so each request keeps its own id, auth, or trace span. This chapter races five asyncio tasks with a global versus a ContextVar and shows the contamination.
Mental model
ContextVar.set(v) → token
await … (other tasks may run)
ContextVar.get() → still v for this task
ContextVar.reset(token)
Worked examples
Verified with CPython 3.15.0.
import asyncio, contextvars
req_id = contextvars.ContextVar("req_id", default="-")
async def handle(i):
token = req_id.set(f"req-{i}")
try:
await asyncio.sleep(0.01)
return req_id.get()
finally:
req_id.reset(token)
async def naive_shared():
global GID
async def h(i):
global GID
GID = f"req-{i}"
await asyncio.sleep(0.01)
return GID
return await asyncio.gather(*[h(i) for i in range(5)])
async def main():
good = await asyncio.gather(*[handle(i) for i in range(5)])
print("ContextVar results:", good)
bad = await naive_shared()
print("global mutation results:", bad)
asyncio.run(main())Output:
3.15.0 (main, Oct 9 2026, 15:24:40) [Clang 22.1.3 ]
ContextVar results: ['req-0', 'req-1', 'req-2', 'req-3', 'req-4']
global mutation results: ['req-4', 'req-4', 'req-4', 'req-4', 'req-4']
Trap
Setting a ContextVar in middleware but forgetting reset. Leaks the value into the next task that reuses the context. Prefer token + try/finally, or a context manager wrapper.
Boring rule
Request ids, tenants, and trace context go in ContextVars (or structured context objects), never in module globals. Middleware must reset.
Try this
- Plug a
ContextVarintologging.FilterviaLoggerAdapter/ filter. - Show that threads need
copy_context().run(...)explicitly. - Read PEP 567.
Sources
- contextvars — Python 3.15 docs
- PEP 567