ContextVar: Async-Safe Request Context
ContextVar (Python 3.7+) is the async-safe replacement for threading.local. Each task gets its own isolated copy of the variable; values set in a parent task propagate to children but mutations don't leak back. The right tool for request IDs, trace IDs, user context, and any per-request state in async code.
What it is
ContextVar (Python 3.7+) is the async-safe equivalent of thread-local storage. Each asyncio Task gets its own isolated context; values set in one task don't leak to siblings or back to the parent.
This is the Python answer to a problem every async framework runs into: how to propagate per-request data (trace ID, user, tenant) across await points without passing it as an argument to every function.
Why threading.local is wrong for async
threading.local gives one slot per OS thread. Asyncio runs many tasks on one thread (often dozens or thousands). They all share the same threading.local instance. Set request_id in task A, await something, switch to task B, task B sees task A's request_id. Race conditions everywhere; logs interleaved.
ContextVar fixes this by being per-task. The runtime copies the parent's context when a new task is created; subsequent mutations stay within the task.
The standard pattern: request context
Every async web service ends up with this shape:
- Declare
ContextVars at module scope:request_id,user_id,tenant, etc. - Middleware sets them at request entry.
- Anywhere downstream (handlers, DB calls, log filters) reads them as needed.
- The values disappear automatically when the task ends.
Frameworks like FastAPI and Starlette use ContextVar internally for their Request injection. structlog has a contextvars integration that auto-binds context values to log lines. opentelemetry uses ContextVar for trace propagation.
Propagation rules
The rules are subtle but consistent:
asyncio.create_task(coro)captures the current context at task creation. The new task starts with a copy.- Mutations inside the task don't escape (sibling and parent contexts are independent).
asyncio.to_thread(fn, ...)copies the current context to the thread.concurrent.futures.ThreadPoolExecutor.submit(fn)does NOT copy. Wrap the function incontextvars.copy_context().run(fn, ...)for propagation.loop.run_in_executor(None, fn)follows the executor's behaviour (default executor is a ThreadPoolExecutor, so no propagation by default).
The asymmetry between to_thread (copies) and ThreadPoolExecutor.submit (does not) is a well-known foot-gun. Prefer to_thread when possible.
When ContextVar isn't needed
For function-local state, just pass arguments. ContextVar is for genuinely cross-cutting concerns (logging, tracing, auth) that would clutter every signature otherwise.
For thread pools without a context-propagation requirement, leave ContextVar alone; threading.local or plain function arguments are simpler.
For purely sync code, threading.local is still fine. Reach for ContextVar in sync code only when the per-call scoping that Context.run provides is needed.
Primitives by language
- contextvars.ContextVar (declare a var)
- var.set(value) returns a Token (for var.reset(token))
- var.get(default) reads (raises LookupError if no default and unset)
- contextvars.copy_context() / Context.run(callable, *args)
- asyncio.create_task copies the current context for the new task
Implementation
threading.local is per-OS-thread. Asyncio runs many tasks on one thread, so they all share the same threading.local instance. ContextVar is per-task, which is the right semantic.
1 import asyncio
2 import threading
3 from contextvars import ContextVar
4
5 # BAD: shared across tasks on the same thread
6 thread_local = threading.local()
7
8 async def bad_handler(req_id):
9 thread_local.req_id = req_id
10 await asyncio.sleep(0.01)
11 # Race! Other tasks may have overwritten thread_local.req_id
12 return thread_local.req_id
13
14 # GOOD: per-task isolation
15 request_id: ContextVar[str] = ContextVar("request_id")
16
17 async def good_handler(req_id):
18 request_id.set(req_id)
19 await asyncio.sleep(0.01)
20 # Always returns this task's req_id, even if siblings set their own
21 return request_id.get()
22
23 async def main():
24 await asyncio.gather(*[good_handler(f"r{i}") for i in range(10)])Standard pattern in async web services: middleware sets request_id, logger automatically includes it. structlog's contextvars integration handles this automatically.
1 import logging
2 import os
3 from contextvars import ContextVar
4 from fastapi import FastAPI, Request
5
6 request_id: ContextVar[str] = ContextVar("request_id", default="-")
7
8 class ContextFilter(logging.Filter):
9 def filter(self, record):
10 record.request_id = request_id.get()
11 return True
12
13 logger = logging.getLogger()
14 logger.addFilter(ContextFilter())
15
16 app = FastAPI()
17
18 @app.middleware("http")
19 async def add_request_id(request: Request, call_next):
20 rid = request.headers.get("x-request-id", "auto-" + os.urandom(4).hex())
21 request_id.set(rid)
22 return await call_next(request)
23
24 @app.get("/")
25 async def handler():
26 logger.info("processing") # log line includes request_id automatically
27 return {"ok": True}asyncio.create_task copies the current context. Sets in the parent before the task starts are visible; sets in the parent after the task starts are NOT (each task gets its snapshot).
1 import asyncio
2 from contextvars import ContextVar
3
4 tenant: ContextVar[str] = ContextVar("tenant")
5
6 async def child():
7 await asyncio.sleep(0.01)
8 return tenant.get()
9
10 async def main():
11 tenant.set("acme")
12 t1 = asyncio.create_task(child()) # captures "acme"
13
14 tenant.set("globex")
15 t2 = asyncio.create_task(child()) # captures "globex"
16
17 # Mutating tenant after the task started has no effect on it
18 tenant.set("initech")
19
20 print(await t1) # "acme"
21 print(await t2) # "globex"
22 print(tenant.get()) # "initech"copy_context() captures the current context. Context.run(fn, *args) runs fn in that snapshot; mutations inside fn don't escape. Useful for "set a few values, run something, then forget it ever happened".
1 from contextvars import ContextVar, copy_context
2
3 mode: ContextVar[str] = ContextVar("mode", default="prod")
4
5 def show():
6 print(f"mode = {mode.get()}")
7
8 mode.set("prod")
9 ctx = copy_context()
10
11 # Mutate inside ctx; doesn't affect outer
12 def with_debug():
13 mode.set("debug")
14 show() # debug
15
16 ctx.run(with_debug)
17 show() # prod (outer context unchanged)Key points
- •Each asyncio Task gets a copy of the parent's context. Sets in the task don't affect siblings or parent.
- •Replaces threading.local for async: thread-locals don't isolate across asyncio tasks on the same thread.
- •Used for request-scoped data: trace ID, request ID, authenticated user, tenant ID.
- •var.set() returns a Token; pass it to var.reset(token) to restore the previous value (rare in async code).
- •Frameworks (FastAPI, Starlette, structlog) use ContextVar internally for request-scoped state.
Follow-up questions
▸ContextVar vs threading.local: when each?
▸Does ContextVar work with thread pools?
▸Can a ContextVar have no default?
▸Why does var.set() return a Token?
Gotchas
- !threading.local in async code: tasks on the same thread share state, race city
- !ThreadPoolExecutor.submit does NOT copy context; trace IDs disappear in pool work
- !var.set() inside a finally block of a task does NOT affect the parent (per-task isolation)
- !Forgetting default + var.get() raises LookupError; catch or pre-set in middleware
- !Context is captured at create_task time, not at await time; mutations between create and await don't propagate