Applications often need small pieces of context to follow a request through several layers: a request ID, tenant identifier, locale, or tracing field. Passing every value through every function is explicit, but can become noisy when the value is cross-cutting rather than part of the function’s business input.
Python’s contextvars module provides context-local state designed to work with asynchronous code.
Why a normal global is unsafe
A module-level variable is shared by all concurrent requests:
current_request_id = NoneIf two asynchronous tasks update that variable, one request can observe the other’s value. Thread-local storage solves a different problem because many async tasks can run on the same thread.
A ContextVar gives each logical execution context its own value.
Define context variables once
Create the variable at module scope:
from contextvars import ContextVar
request_id = ContextVar("request_id", default="unknown")Code deeper in the call stack can read it without receiving an additional parameter:
def log_message(message: str) -> None:
print(request_id.get(), message)The default is useful for code that can run outside a request, such as startup tasks or tests.
Set and reset values at a clear boundary
Set context when a request enters the application:
def handle_request(req):
token = request_id.set(req.id)
try:
return dispatch(req)
finally:
request_id.reset(token)set() returns a token representing the previous state. Resetting with that token restores the value correctly even when contexts are nested.
The finally block matters. Without cleanup, long-lived workers can retain context longer than intended.
Context follows asyncio tasks
contextvars integrates with asyncio so task-local context is preserved across await boundaries.
import asyncio
from contextvars import ContextVar
request_id = ContextVar("request_id")
async def child():
await asyncio.sleep(0)
return request_id.get()
async def main():
request_id.set("req-42")
assert await child() == "req-42"This is the primary advantage over thread-local state in asynchronous applications.
When new tasks are created, they receive the current context according to the runtime’s task-context behavior. Treat later changes as local state rather than a shared mutable object.
Store identifiers, not large mutable objects
Context variables are best for small pieces of ambient metadata.
Good candidates include:
- request or trace IDs;
- locale;
- tenant or account identifier;
- logging fields;
- a lightweight security principal reference.
Avoid placing large request objects or broadly mutable dictionaries in context merely to make them globally reachable. That obscures dependencies and can retain data longer than expected.
A context-local reference to a mutable object is still a reference to mutable state. ContextVar isolates bindings; it does not automatically deep-copy objects.
Keep business inputs explicit
Ambient context should not replace normal function parameters.
If a function calculates tax for a region, the region is probably a business input and should be explicit:
def calculate_tax(amount, region):
...If the same function also emits a trace record, a request ID used only by logging can reasonably come from context.
A useful distinction is whether changing the value changes the function’s business result. If it does, explicit parameters usually make dependencies clearer and tests easier.
Use defaults deliberately
A missing value can mean either “this operation is outside a request” or “the application forgot to initialize context.”
For mandatory state, omit the default so get() raises LookupError when initialization is missing:
tenant_id = ContextVar("tenant_id")For optional diagnostic state, a harmless default may be appropriate.
Choose based on whether missing context is a valid condition.
Test isolation between concurrent tasks
Concurrency tests should verify that values do not cross request boundaries:
async def worker(value):
token = request_id.set(value)
try:
await asyncio.sleep(0)
return request_id.get()
finally:
request_id.reset(token)Run multiple workers concurrently and confirm each receives its own value. This catches regressions where context-local state is accidentally replaced by a global cache or mutable singleton.
Common pitfalls
Forgetting to reset context
Always restore state at the boundary that set it. try and finally make the ownership clear.
Hiding important business dependencies
Context is convenient, but invisible inputs make code harder to understand. Reserve it for cross-cutting state.
Storing mutable containers and assuming isolation
Separate contexts can still point to the same mutable object if that object was shared before assignment.
Using thread locals for async request state
Async tasks can interleave on one thread. Use mechanisms designed for logical task context.
Treat context as infrastructure
contextvars is most effective for infrastructure concerns that should follow execution without becoming arguments everywhere. Define variables centrally, initialize them at request or job boundaries, reset them reliably, and keep the stored values small.
Used this way, context-local state improves logging and tracing without turning application logic into a collection of hidden global dependencies.