PEP 567 Context and ContextVar objects, context copying on task creation, and asyncio integration.
Context variables provide task-local state for concurrent Python programs. They are implemented by the contextvars module and are designed mainly for asynchronous code, where many logical tasks can run on the same operating system thread.
A context variable looks like a global variable from the caller’s point of view, but its value belongs to the current logical execution context.
This solves a problem that thread-local storage cannot solve cleanly.
import contextvars
request_id = contextvars.ContextVar("request_id")
request_id.set("req-123")
print(request_id.get())Output:
req-123The value is not stored globally in the variable object. It is stored in the current context.
50.1 Why Context Variables Exist
Thread-local storage works when each request or unit of work has its own OS thread.
import threading
local = threading.local()
def handle_request(id):
local.request_id = id
process()This breaks down for async code.
In async programs, many tasks share one thread:
one OS thread
event loop
task A
task B
task CIf all tasks share one thread, then thread-local state is shared by all tasks in that thread.
Context variables attach state to the logical context, not merely the OS thread.
50.2 The Core Problem
Suppose an async server wants each request to have a request ID.
A naive global variable fails:
current_request_id = None
async def handle_request(id):
global current_request_id
current_request_id = id
await do_work()
return current_request_idIf two requests interleave, one can overwrite the other’s value.
task A sets request_id = A
task A awaits
task B sets request_id = B
task B awaits
task A resumes and sees BContext variables prevent this by giving each task its own context value.
50.3 Basic ContextVar
Create a context variable:
from contextvars import ContextVar
request_id = ContextVar("request_id")Set a value:
request_id.set("abc")Get a value:
print(request_id.get())If no value exists, get() raises LookupError unless a default is provided.
request_id = ContextVar("request_id", default=None)
print(request_id.get())Output:
None50.4 ContextVar Objects Are Keys
A ContextVar object is a key into the current context.
current context
request_id variable -> "req-123"
user_id variable -> "user-9"The variable object itself does not hold one global value.
This is why the same ContextVar can return different values in different tasks.
request_id.get()means:
look up request_id in the current context50.5 Tokens
set() returns a token.
token = request_id.set("req-123")The token records the previous state for that variable in that context.
Use reset() to restore the previous value:
request_id.reset(token)This is important for scoped changes.
token = request_id.set("inner")
try:
run_inner()
finally:
request_id.reset(token)Without reset, the value remains changed in the current context.
50.6 Scoped Context Values
A helper function can manage scoped values.
from contextlib import contextmanager
from contextvars import ContextVar
request_id = ContextVar("request_id", default=None)
@contextmanager
def bind_request_id(value):
token = request_id.set(value)
try:
yield
finally:
request_id.reset(token)Usage:
with bind_request_id("req-1"):
print(request_id.get())
print(request_id.get())Inside the block, the value is set. After the block, the previous value is restored.
50.7 Async Task Isolation
Context variables are especially useful with asyncio.
import asyncio
from contextvars import ContextVar
request_id = ContextVar("request_id")
async def inner():
await asyncio.sleep(0)
print(request_id.get())
async def handle(id):
request_id.set(id)
await inner()
async def main():
await asyncio.gather(
handle("A"),
handle("B"),
)
asyncio.run(main())Expected output shape:
A
BEven though both tasks run on the same thread, each task keeps its own context value.
50.8 Context Capture on Task Creation
When an asyncio task is created, it captures the current context.
import asyncio
from contextvars import ContextVar
var = ContextVar("var", default="unset")
async def show():
print(var.get())
async def main():
var.set("before task")
task = asyncio.create_task(show())
var.set("after task")
await task
asyncio.run(main())The task sees:
before taskThe context was copied when the task was created.
This behavior is central. Context variables follow logical task creation, not later global mutation.
50.9 Context Propagation
Context propagation means carrying context values across execution boundaries.
In async code, task creation usually captures context.
In threaded code, context is not automatically shared with new threads in the same way.
Example:
from contextvars import ContextVar
import threading
var = ContextVar("var", default="unset")
var.set("main")
def worker():
print(var.get())
threading.Thread(target=worker).start()The worker thread may see:
unsetbecause it has a separate context.
If you want propagation, copy the context explicitly.
50.10 copy_context
Use copy_context() to capture the current context.
from contextvars import copy_context
ctx = copy_context()Run a callable inside that context:
ctx.run(function, arg1, arg2)Thread propagation example:
from contextvars import ContextVar, copy_context
import threading
var = ContextVar("var", default="unset")
var.set("main")
ctx = copy_context()
def worker():
print(var.get())
thread = threading.Thread(target=ctx.run, args=(worker,))
thread.start()
thread.join()Now the worker runs with the copied context.
50.11 Context Objects
A Context is a mapping-like object containing context variable bindings.
from contextvars import ContextVar, copy_context
var = ContextVar("var")
var.set("value")
ctx = copy_context()
print(ctx)
print(ctx.get(var))A context stores bindings from ContextVar objects to values.
You usually do not mutate a context directly. You run code inside it.
ctx.run(callable)50.12 Contexts Are Immutable-Like Snapshots
A copied context behaves like a snapshot of the current bindings.
var.set("A")
ctx = copy_context()
var.set("B")
ctx.run(lambda: print(var.get()))
print(var.get())Output:
A
BThe copied context preserved A.
Running code inside a context can change that context’s values, but it does not mutate the caller’s current context.
50.13 Context Variables vs Thread Locals
| Feature | contextvars | threading.local() |
|---|---|---|
| Scope | Logical context | OS thread |
| Async task support | Good | Poor |
| Thread support | Explicit propagation | Natural per-thread storage |
| Main use | Request/task-local state | Thread-local caches and state |
| Propagation | Copyable context | Bound to thread |
Use contextvars for async request state.
Use threading.local() for state genuinely tied to an OS thread.
50.14 Context Variables vs Globals
A global variable has one binding per module.
current_user = NoneA context variable has one binding per context.
current_user = ContextVar("current_user")Global variable problem:
all tasks share one valueContext variable behavior:
each logical context sees its own valueContext variables are still global objects, but their values are context-specific.
50.15 Request Context Example
from contextvars import ContextVar
request_id = ContextVar("request_id", default="-")
def log(message):
print(f"[{request_id.get()}] {message}")
async def handle_request(id):
token = request_id.set(id)
try:
log("start")
await do_work()
log("end")
finally:
request_id.reset(token)Every function called under handle_request can access the request ID without passing it explicitly.
This is useful for logging, tracing, metrics, and request-scoped configuration.
50.16 Tracing Example
Distributed tracing often needs an implicit current span.
current_span = ContextVar("current_span", default=None)
def start_span(name):
parent = current_span.get()
span = Span(name=name, parent=parent)
token = current_span.set(span)
return span, token
def end_span(token):
current_span.reset(token)Nested calls can create span trees without explicitly passing the parent span through every function.
This is one of the main practical uses of context variables.
50.17 Database Session Example
Some frameworks use context variables to store the current request or session.
current_session = ContextVar("current_session", default=None)
def get_session():
session = current_session.get()
if session is None:
raise RuntimeError("no current session")
return sessionRequest handler:
async def handle_request():
session = open_session()
token = current_session.set(session)
try:
await service()
finally:
current_session.reset(token)
session.close()This avoids passing session through every function, but it also hides a dependency. Use it deliberately.
50.18 Hidden Dependency Cost
Context variables are powerful because they avoid plumbing arguments everywhere.
That same property can make code harder to understand.
This function:
def save_user(user):
session = get_session()
session.save(user)has a hidden dependency on the current context.
A clearer function may be:
def save_user(session, user):
session.save(user)Design rule:
use context variables for cross-cutting execution context
avoid them for ordinary business dataGood uses:
request ID
trace span
auth principal
locale
deadline
transaction context
structured logging fieldsPoor uses:
ordinary function inputs
core domain data
mutable application state
configuration that should be explicit50.19 Defaults
A ContextVar can have a default.
var = ContextVar("var", default="unset")
print(var.get())Or get() can receive a default:
var = ContextVar("var")
print(var.get("fallback"))Difference:
ContextVar default:
stored on variable definition
get(default):
used only for that get callIf neither exists and no value is set, get() raises LookupError.
50.20 Reset Discipline
Always reset scoped values.
Bad:
def handle_request(id):
request_id.set(id)
process()If the same task later handles another unit of work, the previous value may remain.
Better:
def handle_request(id):
token = request_id.set(id)
try:
process()
finally:
request_id.reset(token)Tokens make context variable updates reversible.
50.21 Nested Values
Tokens support nesting.
var = ContextVar("var", default="root")
t1 = var.set("A")
print(var.get())
t2 = var.set("B")
print(var.get())
var.reset(t2)
print(var.get())
var.reset(t1)
print(var.get())Output:
A
B
A
rootEach token restores the previous value for that context variable.
Reset tokens in reverse order.
50.22 Token Misuse
A token belongs to the context and variable that created it.
Do not use a token with the wrong variable.
a = ContextVar("a")
b = ContextVar("b")
token = a.set(1)
b.reset(token)This raises an error.
Do not reset the same token twice.
token = a.set(1)
a.reset(token)
a.reset(token)Tokens are one-use restoration handles.
50.23 Mutable Values
A context variable can store a mutable object.
var = ContextVar("var")
var.set([])
var.get().append(1)This is legal, but it can be dangerous.
The context binding points to the list. Mutating the list mutates the same object.
For request context, prefer immutable or controlled values.
Better:
var.set(("req-1", "user-2"))or store an object whose mutation rules are explicit.
Context variables isolate bindings, not all mutations of referenced objects.
50.24 Copying Context With Mutable Values
Copied contexts copy bindings, not deep object graphs.
var = ContextVar("var")
items = []
var.set(items)
ctx = copy_context()
items.append(1)
ctx.run(lambda: print(var.get()))The copied context sees the same list object.
Output:
[1]Context copying is shallow with respect to stored values.
Use immutable values when isolation matters.
50.25 Context and Task Groups
When using task groups or similar structured concurrency, child tasks generally inherit the context at creation time.
async def main():
request_id.set("req-1")
async with asyncio.TaskGroup() as tg:
tg.create_task(worker())The worker sees the request ID captured when the task was created.
If the parent changes the variable later, the already created task does not automatically track that change.
50.26 Context and asyncio.to_thread
asyncio.to_thread is designed to propagate context to the worker thread.
import asyncio
from contextvars import ContextVar
var = ContextVar("var", default="unset")
def blocking():
print(var.get())
async def main():
var.set("async-context")
await asyncio.to_thread(blocking)
asyncio.run(main())The blocking function sees the async context value.
This makes to_thread safer than manually starting a thread when context propagation matters.
50.27 Context and Executors
Context propagation to executor workers depends on the API and version behavior.
A safe explicit pattern is:
import asyncio
from contextvars import copy_context
async def main():
loop = asyncio.get_running_loop()
ctx = copy_context()
await loop.run_in_executor(None, ctx.run, blocking_function)This ensures the callable runs inside the copied context.
Use explicit context capture when correctness depends on it.
50.28 Context and Generators
Context variables interact with generators according to where execution occurs.
A generator resumes in the context active when it is advanced.
This can matter when a generator is passed between execution contexts.
For most code, this is not visible. It becomes important in frameworks that schedule generators, coroutines, callbacks, or greenlet-like execution manually.
50.29 Context and Callbacks
Callbacks run in the context chosen by the scheduler.
In asyncio, callbacks scheduled with event loop APIs often capture or use context according to the event loop’s rules.
Framework authors must be careful to preserve context when moving work across callbacks, threads, or queues.
Application authors mostly see this through logging and tracing correctness.
50.30 Context and Greenlets
Some concurrency frameworks use greenlets or coroutines outside standard asyncio.
Context variable support depends on how the framework integrates with Python context management.
Frameworks that switch execution manually must preserve and restore contexts correctly.
This is an implementation responsibility for the scheduler.
50.31 Context Variables in Libraries
Libraries should avoid creating surprising global context requirements.
Good library use:
read current trace context if present
include request ID in logs if present
support explicit argument override
provide clear context manager for bindingPoor library use:
requires hidden context for core correctness
silently mutates context in unrelated functions
stores large mutable objects
fails if context is missing without clear errorA library should make context behavior explicit in its API.
50.32 Context Manager Pattern
A common pattern:
from contextlib import contextmanager
from contextvars import ContextVar
current_user = ContextVar("current_user", default=None)
@contextmanager
def user_context(user):
token = current_user.set(user)
try:
yield
finally:
current_user.reset(token)Usage:
with user_context(user):
run()This gives callers a visible boundary for context changes.
50.33 Async Context Manager Pattern
from contextlib import asynccontextmanager
from contextvars import ContextVar
current_request = ContextVar("current_request", default=None)
@asynccontextmanager
async def request_context(request):
token = current_request.set(request)
try:
yield
finally:
current_request.reset(token)Usage:
async with request_context(request):
await handle()This pattern works naturally with async request lifetimes.
50.34 Context Variables and Logging
Context variables are useful for structured logging.
request_id = ContextVar("request_id", default="-")
def log(message):
print(f"request_id={request_id.get()} message={message}")Inside request handling:
token = request_id.set("req-42")
try:
log("start")
await service()
log("done")
finally:
request_id.reset(token)Every nested function can log with the current request ID.
Production logging systems often inject context variables through filters, adapters, or structured log processors.
50.35 Context Variables and Cancellation
Async cancellation can interrupt code.
Always reset context variables in finally.
token = request_id.set("req-1")
try:
await work()
finally:
request_id.reset(token)If work() is cancelled, the finally block still runs.
Without finally, cancelled tasks may leave stale context values in reused execution contexts.
50.36 Context Variables and Testing
Tests using context variables should reset state.
Bad:
def test_one():
request_id.set("test-one")This can leak into later tests running in the same context.
Better:
def test_one():
token = request_id.set("test-one")
try:
assert request_id.get() == "test-one"
finally:
request_id.reset(token)A fixture can manage this pattern.
50.37 Context Variables and Performance
Context variables are efficient enough for request context, tracing, and logging.
But they still have cost.
Avoid using them for extremely hot inner-loop data flow when an explicit local variable would be simpler and faster.
Good:
request_id.get()inside logging or tracing.
Poor:
for item in millions:
value = current_value.get()
compute(value, item)Pass hot-loop values explicitly.
50.38 CPython Internals
At the CPython level, context variables are integrated with thread state and task scheduling.
A thread has a current context.
Async task machinery saves and restores context as tasks are scheduled.
Conceptually:
thread state
current context
asyncio task
saved context
when task runs:
thread state's current context = task context
when task yields:
task saves updated contextThis is why context variables can behave as task-local state on top of a single OS thread.
50.39 Context and Frames
Context variable lookup does not search Python stack frames.
It searches the current context.
This is different from local variables, globals, and closures.
local variable:
stored in frame
global variable:
stored in module dictionary
closure variable:
stored in cell
context variable:
stored in current ContextThis distinction matters when reasoning about execution state.
50.40 Context Propagation Boundaries
Be alert at boundaries where context may or may not propagate:
creating an asyncio task
calling into a thread
using an executor
scheduling callbacks
crossing process boundaries
calling native code
using custom schedulers
using greenletsWhen correctness depends on context, test the boundary explicitly.
50.41 Cross-Process Behavior
Context variables do not automatically cross process boundaries.
If you use multiprocessing, subprocesses, or external workers, propagate context explicitly.
Example:
payload = {
"request_id": request_id.get(),
"data": data,
}
send_to_worker(payload)The receiving process can bind the value to its own context.
50.42 Cross-Interpreter Behavior
Subinterpreters have separate interpreter state. Context variables and their values should be treated as interpreter-local unless an explicit communication mechanism transfers data.
Pass neutral data, such as strings or bytes, across interpreter boundaries.
Do not assume context objects or context variable bindings can be shared directly.
50.43 API Design With Context Variables
A good API often supports both explicit and implicit context.
Example:
current_request_id = ContextVar("current_request_id", default=None)
def emit_event(name, *, request_id=None):
if request_id is None:
request_id = current_request_id.get()
write_event(name, request_id=request_id)This lets callers pass explicit values when needed, while still supporting context-based defaults.
This is better than making hidden context mandatory.
50.44 Common Bugs
| Bug | Cause | Fix |
|---|---|---|
| Value leaks between operations | Missing token reset | Use try/finally |
| Worker thread sees default | Context not propagated | Use copy_context or asyncio.to_thread |
| Async tasks see old value | Context captured at task creation | Set value before creating task |
| Mutation leaks across contexts | Stored mutable object | Store immutable values or copy |
| Tests affect each other | Context not reset | Use fixtures or tokens |
| Hidden dependency | Function reads context silently | Accept explicit argument override |
| Executor loses tracing | Context not copied | Run executor callable under copied context |
50.45 Design Rules
Use context variables for execution context, not ordinary data flow.
Set values at clear boundaries, such as request start, task start, or transaction start.
Always reset using tokens.
Prefer immutable values.
Copy context explicitly when crossing thread or scheduler boundaries.
Do not rely on context variables for security isolation.
Provide explicit parameters for important business data.
Use context managers to make scope visible.
50.46 Minimal Mental Model
Use this model:
A ContextVar is a key.
The current Context maps ContextVars to values.
Each thread has a current Context.
Async tasks save and restore their Context as they run.
Creating a task usually copies the current Context.
Setting a ContextVar changes the current Context.
A token restores the previous binding.
Context values do not automatically cross threads, processes, or interpreters unless explicitly propagated.50.47 Key Points
Context variables provide logical-context-local state.
They solve the problem that thread-local storage cannot solve in async programs.
A ContextVar stores values in the current Context, not on the variable object itself.
set() returns a token that should be reset.
copy_context() captures the current context.
Async tasks generally capture context when created.
Threads require explicit context propagation unless an API does it for you.
Mutable values can still be shared if the binding points to the same object.
Use context variables for request IDs, trace spans, auth context, locale, deadlines, and similar cross-cutting execution state.