Skip to content
← Back to explainers

Python explainer

Carry a Request ID Across Tasks with contextvars

About 4 min to read. Allow extra time to work through examples.

Tag every log line with its request ID using a ContextVar, put back the previous value when a block ends, and learn which tools copy the value into tasks and threads.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your web service writes log lines from many functions. When two requests run at the same time, their lines mix together. You want every line tagged with its request ID, without adding a request_id argument to every function.

A global variable fails here. With asyncio, both requests run in one thread and take turns at each await, so the second request overwrites the first one's ID:

import asyncio

current_id: str | None = None  # one global, shared by every request


async def handle(request_id: str) -> None:
    global current_id
    current_id = request_id
    await asyncio.sleep(0.1)  # the other request runs here
    print(f"[{current_id}] done")


async def main() -> None:
    await asyncio.gather(handle("A"), handle("B"))


asyncio.run(main())  # prints "[B] done" twice

Give each task its own value

A ContextVar is a variable whose value depends on which task reads it. The value lives in a context, a set of variable values, and every asyncio task runs in a context of its own:

import asyncio
from contextvars import ContextVar

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


async def handle(rid: str) -> None:
    request_id.set(rid)
    await asyncio.sleep(0.1)
    print(f"[{request_id.get()}] done")


async def main() -> None:
    await asyncio.gather(handle("A"), handle("B"))


asyncio.run(main())  # prints "[A] done", then "[B] done"

gather wraps each coroutine in a task, and each task gets its own copy of the context. So set in one task changes only that task's copy. Create the variable once, at module level. With default=None, get() returns None when nothing is set. Without a default, it raises LookupError.

Put back what was there before

set returns a token that remembers the previous value. Pass it to reset when your block ends, inside a finally so it also runs after an exception:

from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar

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


@contextmanager
def request_scope(value: str) -> Iterator[None]:
    token = request_id.set(value)
    try:
        yield
    finally:
        request_id.reset(token)


with request_scope("outer"):
    with request_scope("inner"):
        print(request_id.get())  # inner
    print(request_id.get())  # outer
print(request_id.get())  # None

A common mistake is to call request_id.set(None) when the block ends. That works until you nest two scopes. Then the inner scope replaces the outer ID with None instead of putting it back.

Know which code gets a copy

When you... the new code sees...
await a coroutine directly your context. It reads and changes your values.
create a task with create_task, a TaskGroup or gather a copy of your context, taken when the task is created
call asyncio.to_thread(fn) a copy of your context, in the worker thread
call loop.run_in_executor or pool.submit no copy. The worker thread has its own context.
put a job on a queue only what you put in the job

Values a child task sets stay in its copy, and the parent keeps its own. The copy is shallow, though. If the value is a list, both contexts share one list, so store strings, numbers or tuples.

For a thread pool, copy the context yourself for each job and run the function through ctx.run:

import contextvars
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar

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


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


request_id.set("A")
with ThreadPoolExecutor() as pool:
    plain = pool.submit(log, "plain submit")
    ctx = contextvars.copy_context()
    copied = pool.submit(ctx.run, log, "copied context")
    print(plain.result())  # [None] plain submit
    print(copied.result())  # [A] copied context

Take a fresh copy for every job. A context can run in only one thread at a time, and a second thread that tries to enter it gets a RuntimeError.

Check your understanding A parent task sets request_id to "A", then starts a child task that sets it to "CHILD". What does the parent read after the child finishes? Show answer

"A". The child got a copy of the parent's context when it was created, and its set changed only that copy. That copy keeps each request's ID inside its own tasks.

Going further

In Python 3.14, a token also works as a context manager, as in with request_id.set("A"):. Python 3.14 also lets you pass context= to threading.Thread. On free-threaded builds, a new thread starts with a copy of the context of the thread that started it. So the plain submit above can print A too. A pool thread keeps that copy for every later job, so copy the context per job anyway.

Read more in the contextvars documentation.

step 45 of 56

Concurrency with Threads and asyncio

Up next Build a Request ID That Follows Tasks and Threads