Request-scoped context with contextvars

Concurrency in Python services Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

Two requests set their own id concurrently. What's printed?

Python 3.13
import asyncio
import contextvars

request_id = contextvars.ContextVar("request_id", default="-")


async def handle(rid: str, out: list[str]) -> None:
    request_id.set(rid)
    await asyncio.sleep(0)
    out.append(f"{rid}:{request_id.get()}")


async def main() -> None:
    out: list[str] = []
    async with asyncio.TaskGroup() as tg:
        tg.create_task(handle("r1", out))
        tg.create_task(handle("r2", out))
    print(sorted(out), request_id.get())


asyncio.run(main())

Options
Hint 1 of 1

Does each task get its own context?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A ['r1:r1', 'r2:r2'] -

Correct. Each task has its own copy; the main task never set one.

Each asyncio task starts with a copy of the current context, so a set() inside a task only changes that task's copy. The parent's context never changed, so it still has the default.

Why not the others?
  • B ['r1:r2', 'r2:r2'] r2. That's what a shared global would do.
  • C ['r1:r1', 'r2:r2'] r2. Child tasks' sets don't leak to the parent.
  • D A LookupError. The variable has a default.

30-second answer

Store it in a contextvars.ContextVar. Each asyncio task runs in a copy of the context, so setting the request id at the start of a request affects only that request's code and the tasks it creates, even while thousands of requests interleave on one thread. A logging filter or structured logger reads the variable and adds it to every record. Thread-locals don't work for this in async code.

Practise saying it · 30 s. No recording, just a timer

Request context in async Python.

Say your answer out loud, then check which of these points you covered:

  • ContextVar
  • Copied per task
  • Logger reads it
  • Not thread-locals

Deep dive

Tasks copy the context when created; changes in a child task don't leak to the parent.

OpenTelemetry stores the current span in contextvars too.

Reset with the token returned by set() when the request ends, if the context is reused.

to_thread copies the current context into the worker thread.

Common mistakes

  • Thread-locals in async code (all requests on one thread share them).
  • Globals for per-request data.
  • Forgetting to propagate ids to background jobs.

Senior take & follow-ups

ContextVar per request, copied per task, read by the logger.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why not threading.local?

    Show hint

    All coroutines on a loop share one thread.

  2. Does a child task see the parent's value?

    Show hint

    Yes, it gets a copy at creation.

Suggest a fix if something here is wrong or out of date.