Python Context Variable: Scoped State for Async and Threads
python context variable: Learn how Python's contextvars module provides async-safe, scoped state that works across tasks and threads without passing arguments everywhere.
When a request handler needs to carry request-specific data through a call chain without threading it through every function signature, a Python context variable is often the cleanest solution. The contextvars module, introduced in Python 3.7, provides a way to store state that is automatically propagated across asynchronous tasks and, with care, across threads.
Creating and Using a ContextVar
A ContextVar is created with a name and an optional default value. Once created, you can set, get, and reset its value within a specific context.
from contextvars import ContextVar request_id = ContextVar('request_id', default=None) # Inside a request handler request_id.set('abc-123') print(request_id.get()) # abc-123 # Later, when the request is done request_id.reset(token)
The set() method returns a token that you pass to reset() to restore the previous value. This is important for keeping state clean when a request finishes or when you need to temporarily override a value.
How Context Propagation Works
Each thread and each asyncio task has its own context object. When you set a value on a ContextVar, it changes the value in the current context. When you create a new task or thread, Python copies the current context so the child can see the same values as the parent at the moment of creation.
import asyncio from contextvars import ContextVar var = ContextVar('var', default='default') async def child(): print(var.get()) # sees the value from the parent context async def main(): var.set('parent-value') await asyncio.create_task(child()) asyncio.run(main())
This copy-on-create behavior is what makes context variables safe for concurrency: each task gets its own isolated view, so changes in one task don't leak into another.
Context Variables in Asynchronous Code
The primary motivation for contextvars was to fix the limitations of thread-local storage in asyncio. With asyncio, many coroutines run on the same thread, so threading.local cannot distinguish between them. Context variables solve this by tying state to the logical execution context rather than the physical thread.
When you use asyncio.create_task(), the task receives a copy of the current context. If you need a task to start with a fresh context, you can explicitly pass a new context using contextvars.copy_context().
import asyncio from contextvars import ContextVar, copy_context var = ContextVar('var') async def child(): print(var.get()) async def main(): var.set('original') ctx = copy_context() # Run child in a separate context await asyncio.create_task(ctx.run(child)) asyncio.run(main())
Context Variables vs Thread-Local Storage
Before contextvars, developers often used threading.local to store request-scoped state. That works for traditional multithreaded servers, but it breaks under asyncio because all coroutines share the same thread. Context variables provide a unified mechanism that works both in threads and in asyncio tasks.
| Feature | threading.local | contextvars.ContextVar |
|---|---|---|
| Works across asyncio tasks | No | Yes |
| Works across threads | Yes | Yes, with explicit context copying |
| Automatic propagation to child tasks | No | Yes |
| Reset mechanism | No built-in | Token-based reset |
For new code that needs scoped state, ContextVar is the safer choice because it behaves consistently in both synchronous and asynchronous environments.
Common Pitfalls and Limitations
Context variables are not a silver bullet. They do not automatically propagate across threads unless you explicitly copy the context. If you start a new thread with threading.Thread, it does not inherit the caller's context by default. You must pass the context manually if you need that behavior.
Another pitfall is forgetting to reset a variable after use. If you set a value in a long-lived task and never reset it, the value persists for the lifetime of that task, which can cause subtle bugs. Always use the token returned by set() to reset the value when the scope ends.
Context variables also have a small performance cost. Each set() and get() involves a dictionary lookup, and copying a context when creating a task has overhead. In most applications this is negligible, but in extremely hot paths you should measure before optimizing.
Performance and Overhead Considerations
The cost of a context variable operation is comparable to a dictionary access. Creating a new context with copy_context() copies the entire mapping, which is O(n) where n is the number of variables. For typical request-scoped data (a handful of variables), this overhead is minimal. However, if you create thousands of tasks per second and each task copies a context with many variables, you might see measurable overhead.
If performance becomes a concern, consider reducing the number of context variables or using a single variable that holds a small object. Also, avoid setting context variables in tight loops; instead, set them once at the entry point of a task or request.
Practical Example: Request-Scoped Data in a Web Server
Here is a complete example showing how to use a context variable to store the current request ID in an asyncio-based web server handler.
import asyncio from contextvars import ContextVar request_id = ContextVar('request_id', default='unknown') async def handle_request(request): # Simulate setting the request ID at the start of handling token = request_id.set(request.id) try: await process_request() finally: request_id.reset(token) async def process_request(): # Any function called from here can access the request ID print(f"Processing request {request_id.get()}") class Request: def __init__(self, id): self.id = id async def main(): await handle_request(Request('req-001')) await handle_request(Request('req-002')) asyncio.run(main())
This pattern keeps the request ID accessible to any function in the call stack without passing it explicitly. It works correctly even when multiple requests are interleaved on the same thread, because each task has its own context copy. For a production server, you would set the context variable in middleware before the request handler runs and reset it after the response is sent.