Passing a request ID through every function is explicit, but after a few layers it can become noise. Logging is the example I keep running into: the logger needs the request ID, while most business functions do not actually care about it.

A global variable looks tempting until two requests run concurrently. threading.local() fixes a different problem, but one event-loop thread can execute many asyncio tasks. Python’s contextvars module is designed for this kind of context-local state.

Here’s the idea: put cross-cutting metadata in a ContextVar, keep ordinary application data in normal parameters, and be deliberate about where the context starts and ends.

A global variable is not request-local

Imagine an async service doing this:

current_request_id = None


async def handle(request):
    global current_request_id
    current_request_id = request.id
    await save_order(request.order)

While save_order() is suspended, another task can change current_request_id. The first task then resumes with somebody else’s value.

A ContextVar gives each execution context its own binding:

from contextvars import ContextVar

request_id: ContextVar[str] = ContextVar("request_id")


def log(message: str) -> None:
    print(f"[{request_id.get()}] {message}")


async def save_order(order) -> None:
    log(f"saving order {order.id}")

Context variables should normally be declared at module scope. A Context keeps strong references to its variables, so creating them dynamically in closures can keep those variables alive longer than expected.

Set at the boundary, reset on exit

I prefer to bind request metadata at the outer boundary and restore the previous value in finally:

async def handle(request):
    token = request_id.set(request.id)
    try:
        log("request started")
        await save_order(request.order)
        log("request finished")
    finally:
        request_id.reset(token)

set() returns a token representing the previous binding. Resetting with that token matters for nested code as well as cleanup.

For example, an inner operation can temporarily replace the value:

async def run_job(job):
    token = request_id.set(f"job:{job.id}")
    try:
        await process(job)
    finally:
        request_id.reset(token)

When run_job() finishes, the caller’s request ID becomes visible again. I would not replace reset() with set(old_value): a variable can previously have been unset, which is different from having a chosen sentinel value.

Decide what an unset value means

A context variable does not need a default:

request_id = ContextVar("request_id")

Calling request_id.get() without a binding then raises LookupError. That is useful when missing context is a programming error.

For infrastructure code such as logging, a default can be more practical:

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

I usually make this choice intentionally. A default is convenient, but it can also hide a request boundary that forgot to initialize its context.

asyncio tasks copy their creation context

contextvars integrates directly with asyncio. A newly created task normally receives a copy of the current context.

import asyncio


async def child() -> None:
    await asyncio.sleep(0)
    log("child task")


async def handle(request):
    token = request_id.set(request.id)
    try:
        task = asyncio.create_task(child())
        await task
    finally:
        request_id.reset(token)

The important boundary is task creation, not the later moment when the child happens to run. That can surprise me with long-lived background tasks:

token = request_id.set("req-123")
task = asyncio.create_task(background_worker())
request_id.reset(token)

The background task was created while req-123 was current, so it inherits that context even though the parent immediately resets its own binding.

For truly detached work, inheriting request-scoped values may be wrong. Since Python 3.11, asyncio.create_task() accepts an explicit context argument:

import contextvars

empty_context = contextvars.Context()
task = asyncio.create_task(
    background_worker(),
    context=empty_context,
)

This makes the isolation visible instead of depending on an accidental creation point.

Context is copied, values are not deep-copied

This is an easy distinction to miss. A task gets its own context mapping, but objects stored inside that mapping are still ordinary Python objects.

I avoid putting mutable request state in a ContextVar:

metadata = ContextVar("metadata", default={})  # avoid this

A copied context can still refer to the same dictionary. Mutating it is shared mutation, not context-local mutation.

Immutable values work much better:

from dataclasses import dataclass


@dataclass(frozen=True)
class TraceContext:
    request_id: str
    user_id: str | None


trace_context: ContextVar[TraceContext | None] = ContextVar(
    "trace_context",
    default=None,
)

If I need a changed value, I bind a new immutable object rather than mutating the old one.

Threads have another context boundary

Each thread has its own effective context stack. Plain thread creation does not mean that request context magically becomes shared state.

For asyncio code, asyncio.to_thread() is convenient because it propagates the current context into the worker call:

import asyncio


def blocking_write() -> None:
    log("writing from worker thread")


async def write() -> None:
    await asyncio.to_thread(blocking_write)

That behavior is useful for blocking I/O helpers that still need logging or tracing metadata.

When using lower-level thread APIs, I prefer to make propagation explicit with copy_context():

from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context

pool = ThreadPoolExecutor()


def submit_with_context(fn, *args):
    ctx = copy_context()
    return pool.submit(ctx.run, fn, *args)

copy_context() is documented as O(1), so capturing the current context does not become more expensive merely because an application has many context variables.

One subtle rule is that the same Context cannot be entered concurrently. If several worker submissions need the same logical values, take a separate context copy for each submission rather than reusing one entered Context object.

Keep business inputs explicit

ContextVar is useful, but it can turn into an invisible dependency if everything goes into it.

I would use it for data such as:

  • request and trace identifiers;
  • logging metadata;
  • locale or diagnostic context when it truly follows execution scope.

I would still pass values such as user, database, order, permissions, and feature decisions as normal arguments. Those values affect business behavior and are easier to test and understand when they stay visible in function signatures.

To be fair, this boundary is architectural rather than enforced by Python. My rule is simple: if a function’s result fundamentally depends on a value, that value probably belongs in its arguments.

Test isolation, not just retrieval

A unit test that calls set() and then get() proves very little. The failure mode I care about is cross-task leakage.

import asyncio


async def observe(value: str) -> tuple[str, str]:
    token = request_id.set(value)
    try:
        before = request_id.get()
        await asyncio.sleep(0)
        after = request_id.get()
        return before, after
    finally:
        request_id.reset(token)


async def test_context_is_isolated_between_tasks():
    left, right = await asyncio.gather(
        observe("left"),
        observe("right"),
    )

    assert left == ("left", "left")
    assert right == ("right", "right")

I also test nested restoration and detached background tasks if the application creates them. Those boundaries are where a clean-looking helper can otherwise become a production-only bug.

The practical model

I think of a ContextVar as dynamically scoped metadata attached to an execution context. It is not a safer global dictionary, and it is not a replacement for function parameters.

Used narrowly, it solves a very real problem: infrastructure code can access request-local metadata without threading that metadata through every layer, while concurrent asyncio tasks keep independent bindings. Tokens give nested scopes a reliable restoration mechanism, task creation defines an inheritance boundary, and explicit contexts let detached work opt out.

In the end, the useful part is not saving a few parameters. It is making ambient state follow the unit of execution instead of whichever thread or global variable happened to run last.