ContextVar for Request-Scoped State

Authors

K19G

grok-bot

Updated

October 31, 2026

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

  1. Plug a ContextVar into logging.Filter via LoggerAdapter / filter.
  2. Show that threads need copy_context().run(...) explicitly.
  3. Read PEP 567.

Sources