Programming · free software · the open webSubscribe via RSS

Context variables follow asyncio tasks, not just threads

ContextVar gives asynchronous code task-local state without threading.local() leaking values between coroutines. Task creation, token reset and thread handoffs define where that state begins and ends.

A thread is too wide a boundary for async state

A thread-local value belongs to an operating-system thread. That model works when one thread handles one unit of work at a time, but an event loop commonly interleaves many coroutines on the same thread. If coroutine A stores request identifier "alpha", awaits network I/O, and coroutine B stores "beta", A may resume while the thread-local still says "beta". The scheduler has switched logical work without switching threads. [3]

Python’s ContextVar associates a value with the current execution context. asyncio integrates that context with tasks, so each task can carry values independently while its coroutine is suspended and resumed. This suits metadata such as a trace identifier or locale that many helper functions need to read, while avoiding an explicit parameter at every layer. It does not make a value globally correct: it only gives it a well-defined context boundary. [1] [2]

Task creation captures the starting context

When asyncio.create_task() is called without an explicit context=, it copies the current context for the new task. The new task then runs its coroutine in that copied context. The copy is a starting point, not a shared mutable slot: setting a ContextVar inside one task does not replace that variable’s binding in a sibling task. The task can still mutate objects stored as values, however; context isolation does not deep-copy a dictionary, list or request object. [2] [1]

That timing matters. Suppose code sets trace_id to "job-7", creates three tasks, and then resets the variable. The children inherit the value present when each task was created, while the parent returns to its previous binding after reset. Creating a task before setting the value gives it a different starting context. For task groups and wrappers, ask which code actually creates the task; merely calling a coroutine does not schedule it, so the task creation point is the useful boundary. [2]

import asyncio
from contextvars import ContextVar

request_id = ContextVar("request_id")

async def report():
    await asyncio.sleep(0)
    print(request_id.get())

async def handle(value):
    token = request_id.set(value)
    try:
        await report()
    finally:
        request_id.reset(token)

async def main():
    await asyncio.gather(handle("alpha"), handle("beta"))

asyncio.run(main())
Run with Python 3.7 or newer; expected output is alpha, then beta.

Use tokens to scope temporary bindings

ContextVar.set(value) returns a token describing the binding that came before it. Reset that token in a finally block when the value is temporary. This restores an outer value if one existed, or removes the binding if the variable was previously unset. A plain assignment to a context variable has no matching lexical cleanup, so a later operation in the same task can accidentally observe stale request or transaction metadata. Tokens are single-use and must be reset through the variable that created them. [1] [3]

A module-level declaration is the usual pattern: create the ContextVar once, then set values around a request, job or operation. The context holds strong references to variables, so creating a new variable inside a short-lived closure is unnecessary and can retain it. Choose a default only when the fallback is semantically valid; for required state, leaving the variable without a value makes a missing setup fail with LookupError instead of looking like an ordinary empty value. [1]

A two-task example makes the isolation visible

The program below runs two handlers concurrently. Each sets a distinct value, suspends in report(), and reads the value after resuming. The output is alpha followed by beta with this scheduling pattern; the key property is that neither handler prints the other handler’s identifier. The finally block restores each task’s prior context even if its awaited work later raises. Run it with Python 3.7 or newer, when contextvars support was added. [1] [3]

This example is intentionally small: it demonstrates a context binding, not a request framework, cancellation policy or security mechanism. A context variable is not an access-control boundary, and putting a mutable container in it does not isolate the container. Use ordinary arguments when a dependency should be visible in the function signature, and use context-local state when the value is ambient metadata whose lifetime follows one logical task.

Crossing into threads needs an explicit decision

Context is also associated with threads, but thread handoff is a separate boundary from task creation. asyncio.to_thread() propagates the current context to its worker thread in supported Python versions; lower-level executor submission and custom callback scheduling have their own APIs and context behavior. Check the exact API you use rather than assuming that any function launched elsewhere inherits state. copy_context() and Context.run() provide explicit tools for running synchronous work in a copied context. [1] [3]

Use context variables where implicit, task-scoped metadata makes a call chain easier to compose. Prefer explicit parameters for core inputs, values whose ownership is important, or state that must be serialized to another process. The distinction is practical: task context answers “which logical operation is this code currently part of?”; it does not answer “which data may this caller access?”

References

  1. contextvars — Context Variables
  2. Coroutines and Tasks — asyncio Tasks
  3. PEP 567 – Context Variables

Suggest a correction · All posts