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
Hide 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.