Async servers handle multiple requests on one thread. A regular global can mix one request identifier with another. ContextVar keeps values local to a context and integrates with asyncio tasks.

Set and restore a value

from contextvars import ContextVar

request_id: ContextVar[str | None] = ContextVar("request_id", default=None)


async def process(value: str) -> None:
    token = request_id.set(value)
    try:
        await run_steps()
    finally:
        request_id.reset(token)

Declare the variable at module level. Retain the token from set() and call reset() in finally, including error paths.

Use context variables for request IDs, trace IDs, and logging context. Keep business data as explicit parameters. The structlog guide shows log correlation, while asyncio.TaskGroup covers structured concurrency.

New async tasks normally receive the current context, but threads, processes, queues, and network calls are separate boundaries. Propagate identifiers intentionally. Tests should set and restore context so results do not depend on execution order.

Why a regular global fails

Imagine two requests assigning a module-level current_request_id. The first writes req-a and pauses at an await. The second writes req-b. When the first resumes, it observes req-b. The bug needs no operating-system threads; cooperative scheduling on one event loop is enough.

A local variable avoids the collision, but deep code cannot read it unless every intermediate function accepts and forwards it. ContextVar covers the narrower case of cross-cutting execution metadata. The declaration can live at module scope while its value belongs to the active context. Logging, tracing, and metrics code can inspect it without adding infrastructure parameters to every domain function.

Do not hide business inputs for convenience. Customer identity, authorization decisions, and order totals should remain explicit when they affect behavior. A correlation identifier used only to observe an operation is a better fit.

Propagation across tasks

A task created with asyncio.create_task() captures the context current at creation. A later parent change does not rewrite the child's captured context, and each task can set its own value without changing siblings.

import asyncio
from contextvars import ContextVar

correlation_id = ContextVar("correlation_id", default="missing")


async def child() -> str:
    await asyncio.sleep(0)
    return correlation_id.get()


async def main() -> None:
    token = correlation_id.set("order-42")
    try:
        task = asyncio.create_task(child())
        correlation_id.set("another-value")
        print(await task)  # order-42
    finally:
        correlation_id.reset(token)


asyncio.run(main())

This behavior lets related tasks inherit observation metadata. If a task must start with a clean or specially prepared context, make that policy explicit. Current task APIs can accept a specific context, and copy_context() can capture a context and run code inside the copy.

Tokens restore nested state

The object returned by set() is not the new value. It records enough state to undo that assignment. An inner operation can temporarily replace an identifier, then reveal the outer value again by resetting its token.

outer = request_id.set("req-123")
try:
    inner = request_id.set("req-123-step")
    try:
        print(request_id.get())
    finally:
        request_id.reset(inner)
finally:
    request_id.reset(outer)

Assigning None is not a general substitute for reset(). If a previous value existed, set(None) loses that relationship rather than restoring it. A token belongs to the variable and context that created it, so keep restoration aligned with nesting order.

Add context to logs safely

A clean integration manages context at the application boundary. An HTTP handler validates or generates the request ID, sets it before invoking application code, and restores the token afterward. A log processor adds the current field to each event. Domain services remain independent of the logger implementation.

Treat incoming identifiers as untrusted input. Restrict length and characters, or generate your own value. Otherwise a client can inject confusing or huge text into every log line. Isolation is not secrecy: do not store credentials, session tokens, or unnecessary personal data merely because a value is task-local.

Threads, executors, and processes

Each thread has its own context stack. When offloading synchronous work, check the selected API's contract. asyncio.to_thread() propagates the current context to its callable, while lower-level executor integrations may require copy_context().run. A separate process cannot share this value through memory; required metadata must be included in a message or protocol field.

For outbound HTTP calls, forward only fields defined by the correlation policy. For queue consumers, extract metadata, set the context for one message, and reset it in finally. Without cleanup, a long-lived worker can label the next message with stale information.

Test isolation and cleanup

One useful test starts two tasks with different identifiers, forces scheduling with await asyncio.sleep(0), and asserts that each retains its value. Another raises an exception inside the scope and checks that the earlier value returns. Sequential happy-path tests often miss both failures.

A fixture can call set(), yield to the test, and reset the token during teardown. Never rely on a value left by another test. If application code launches background tasks, await or cancel them before tearing down shared test resources.

Common mistakes and a selection rule

Repeatedly creating a ContextVar inside a function complicates ownership and may leave context-held references. Declare it once at module level. Calling get() without a default can raise LookupError; that is useful when absence means a programming mistake, while an explicit default suits optional metadata.

Choose ContextVar for cross-cutting data whose lifetime is the current execution context. Choose parameters for values in a function's contract, persistent storage for state that must survive the request, and message fields to cross services. Matching mechanism, lifetime, and boundary prevents context-local state from becoming a disguised global.

The official contextvars documentation, accessed July 22, 2026, defines context variables, tokens, and context copying. It solves contextual isolation, not general application state management.

Review checklist

Before shipping an integration, identify where the value originates, which functions may change it, and which block restores it. Every set() should have a corresponding reset() in finally. List external boundaries as well: threads, processes, queues, and network requests each need an explicit propagation policy.

Observe concurrent execution in a test environment. Logs from two interleaved requests should retain distinct identifiers through exceptions and cancellation. Confirm that the value is absent after completion and that identifiers received from clients have strict length and format limits.

Finally, review every read. If a function cannot honor its contract without the value, the data may belong in a required parameter. If absence is valid, an explicit default documents that behavior. This review keeps ContextVar focused on observability and other cross-cutting concerns rather than creating hidden dependencies between application layers.

Include task names and cancellation paths in this review. Framework middleware may finish before detached work completes, so decide whether that work should inherit the request context or receive a new identifier. Document the decision beside the code that creates the task.