Python Contextvars Usage: Async State Without Globals
Practical guide to python contextvars usage: set, get, reset, context propagation in asyncio, threading behavior, and request-scoped state patterns.
Python contextvars usage becomes necessary the moment multiple coroutines or threads start sharing module-level state. A global variable is visible to every task, so one coroutine can overwrite a value another coroutine is still reading. The old workaround for threads was threading.local(), but that does not model asyncio correctly, where many logical tasks share a single OS thread. contextvars solves this by giving each execution context its own isolated copy of every context variable.
What Context Variables Solve in Concurrent Python
When a web server handles several requests concurrently, each request needs its own request ID, user identity, or tenant ID. Passing those values as explicit parameters to every function is verbose and error-prone. Module-level globals are worse: one request can overwrite a value another request depends on.
contextvars provides a per-context storage mechanism. A ContextVar holds a value that is visible only within the current execution context. When asyncio creates a new task, it copies the current context, so each task gets its own isolated view of every context variable. This makes contextvars the recommended way to carry request-scoped state through async code without threading a parameter through every function call.
The same mechanism replaces threading.local() in async programs. threading.local() keys values by OS thread, which collapses when many asyncio tasks run on one thread. Contextvars keys values by execution context, which matches how asyncio actually schedules work.
The Core API: set, get, and reset
A ContextVar is created with a name and an optional default value:
import contextvars request_id = contextvars.ContextVar("request_id", default="unknown")
get() returns the current value, or the default if no value has been set in the current context:
print(request_id.get()) # "unknown"
set() assigns a new value and returns a Token object:
token = request_id.set("req-1001") print(request_id.get()) # "req-1001"
reset(token) restores the value that was active before the corresponding set() call:
request_id.reset(token) print(request_id.get()) # "unknown"
The token is essential when a context variable must be temporarily changed and then restored, for example inside a library function that should not leak its internal state back to the caller.
If a ContextVar has no default and get() is called before any set(), it raises LookupError:
user_id = contextvars.ContextVar("user_id") user_id.get() # LookupError
Passing a default argument at creation time avoids this error for variables that always need a fallback value.
How Context Propagates Through asyncio Tasks
The key behavior to understand is that asyncio copies the current context when a task is created. Both asyncio.create_task() and asyncio.gather() snapshot the context at the moment the task is scheduled. The coroutine body then runs inside that snapshot, and any set() calls inside the task affect only that task's copy.
import asyncio import contextvars request_id = contextvars.ContextVar("request_id", default="unknown") async def worker(name): await asyncio.sleep(0.05) print(f"{name} sees request_id={request_id.get()}") async def main(): request_id.set("req-777") await asyncio.gather(worker("task-a"), worker("task-b")) asyncio.run(main())
Both tasks see req-777 because they were created after request_id.set("req-777") in main(). The context snapshot is taken at task creation, not at the first await inside the task.
Now consider what happens when each task sets its own value:
async def worker(name): request_id.set(f"{name}-id") await asyncio.sleep(0.05) print(f"{name} sees request_id={request_id.get()}") async def main(): await asyncio.gather(worker("task-a"), worker("task-b")) print(f"main sees request_id={request_id.get()}")
Each task sets its own request_id, and the value does not leak back to main() or to the other task. The workers print task-a-id and task-b-id, while main still sees the default unknown. This isolation is the core reason to use contextvars instead of a global variable.
One subtle point: awaiting a coroutine directly, without wrapping it in a task, does not create a new context. The awaited coroutine runs in the same context as the caller. So if handle_request() calls await process_order(), both functions see the same request_id value, and a set() in process_order() is visible after it returns to handle_request().
Using Context Variables Across Threads
Context variables behave differently with threads than with asyncio tasks. Each thread has its own context, and a new thread does not inherit the context of the thread that spawned it.
import threading import contextvars request_id = contextvars.ContextVar("request_id", default="unknown") def worker(): print(f"worker thread sees request_id={request_id.get()}") request_id.set("req-456") t = threading.Thread(target=worker) t.start() t.join()
The worker thread prints unknown, because the new thread starts with a fresh context. The value set in the main thread is not visible there.
To pass context into a thread explicitly, use contextvars.copy_context() and Context.run():
def worker(): print(f"worker thread sees request_id={request_id.get()}") request_id.set("req-456") ctx = contextvars.copy_context() t = threading.Thread(target=ctx.run, args=(worker,)) t.start() t.join()
Now the worker thread prints req-456, because ctx.run() executes the function inside the copied context.
This distinction matters when you mix asyncio with thread pools, such as asyncio.to_thread() or loop.run_in_executor(). Those calls do not automatically propagate the current context to the worker thread. If the worker needs the context, you must pass it explicitly with copy_context() and run().
A Practical Pattern: Request-Scoped State
A common use case is correlating log output with a request ID across many async operations. Define a module-level ContextVar, set it at the start of each request handler, and read it from any helper that needs to log or trace.
import asyncio import contextvars import uuid request_id = contextvars.ContextVar("request_id", default="-") def log(message): print(f"[{request_id.get()}] {message}") async def process_order(order_id): log(f"processing order {order_id}") await asyncio.sleep(0.05) log(f"order {order_id} complete") async def handle_request(): request_id.set(f"req-{uuid.uuid4().hex[:8]}") log("request started") await process_order("A1001") await process_order("B2002") log("request finished") async def main(): await asyncio.gather(handle_request(), handle_request()) asyncio.run(main())
Each handle_request() task runs in its own copied context, so the two requests produce interleaved but correctly attributed log lines. The same pattern works for user identity, tenant ID, or any value that must follow the logical execution path without being passed as an explicit parameter to every function.
If you need to run a block of code in a specific context without creating a task, use copy_context() and run():
ctx = contextvars.copy_context() def callback(): print(request_id.get()) ctx.run(callback)
This is useful for integrating with callback-based APIs or synchronous code that must observe the current context.
Common Pitfalls and How to Avoid Them
The most frequent mistake is forgetting that a set() inside a long-lived task persists for the rest of that task's lifetime. If a server task processes multiple requests sequentially, a request_id set for the first request is still visible when the second request starts. Capture the token and reset it when the request finishes:
async def handle_request(): token = request_id.set(new_id()) try: await do_work() finally: request_id.reset(token)
Another pitfall is assuming that threading.local() and contextvars behave the same. They do not. threading.local() is keyed by OS thread, so it breaks in asyncio where many tasks share one thread. Contextvars is keyed by execution context, which is the correct model for async code.
A third issue is calling get() on a variable that has no default and was never set. This raises LookupError, which can crash a request handler if the variable was expected to have a value. Provide a default at creation time when the variable should always have a fallback.
Finally, be careful with reset() ordering. Tokens should be reset in the reverse order they were set. Resetting out of order is allowed by the API but can restore a value that is no longer meaningful, producing confusing state.
Performance and Operational Considerations
Context variable access is a dictionary lookup in the current context, so it is cheap in normal use. The cost that can grow is copy_context(), which iterates over all context variables in the current context. If a context holds many large objects, every task creation pays that copying cost.
In practice, keep context variables small and few. Store lightweight identifiers or references, not large data structures. If a context variable holds a database connection or a large buffer, consider whether the value really needs to be per-task or whether a shared resource with explicit lifecycle management is more appropriate.
For production observability, contextvars is a clean way to thread a trace ID through async code without adding a parameter to every function. Combined with structured logging, it gives you a consistent correlation key across log lines, even when requests are interleaved in a single process.
When integrating with libraries that spawn their own tasks, check whether those libraries use asyncio.create_task() (which copies the context) or run callbacks directly. If a library creates tasks without copying the context, your context variables may not be visible inside its callbacks. In that case, wrap the callback with ctx.run() using a context captured earlier.