skip to content

Why does contextvars.ContextVar.set inside an asyncio task not affect the caller?

level: middleimportance: must knowfreq 50%

answer

  1. Ambient state and task boundaries
  2. Each task gets its own view
  3. Snapshot taken when the task is created
  4. Writes land in the copy, not the original
  5. Parent to child only, never back

basics

~20 s

asyncio.create_task snapshots the context that is active at the moment of the call and runs the coroutine inside that copy. A ContextVar.set inside the task writes into the copy, so the creator's context is untouched. Propagation is one-way: parent to child, at creation time.

solid answer

~40 s

A `contextvars.ContextVar` resolves against whichever `contextvars.Context` is currently entered, and asyncio decides that per task. When you call `asyncio.create_task(coro)`, the loop takes a snapshot of the caller's context — the same snapshot `contextvars.copy_context()` produces — and every step of that coroutine, including each resumption after an `await`, runs inside the snapshot. `ContextVar.set` rebinds the key in the entered context, which is the task's private copy, so neither the creator nor any sibling task sees the write. `asyncio.TaskGroup.create_task` behaves identically, so group children inherit the group's creating context rather than each other's. Values therefore flow parent to child once, at creation, and never back or sideways. To get something out of a task, return it from the coroutine or write it through an object the parent already holds — not through a ContextVar.

code

python · 13 lines
python
import asyncio, contextvars

user = contextvars.ContextVar("user", default="anonymous")

async def child():
    user.set("reporter")
    return user.get()

async def main():
    print(await asyncio.create_task(child()))  # reporter
    print(user.get())                          # anonymous

asyncio.run(main())

go deeper

for a junior

Recall the headline: code inside an asyncio task reads what the creator had set, but anything it sets stays inside the task. To get a value out of a task, return it from the coroutine.

for a middle

Explain the mechanics: create_task snapshots the current context, the task runs every step inside that copy, and set rebinds the key in the entered context. Be ready to say when the snapshot is taken.

for a senior

Show the production angle: correlation ids that survive interleaving, why a mutable object in a ContextVar quietly restores shared state, and how to hand a task a prepared context instead of relying on ambient capture.

for a principal

Own the boundary question: which state deserves to be ambient at all versus passed explicitly, and what it costs a team when request-scoped data travels invisibly through a codebase that also spans threads and subprocesses.

### What a ContextVar actually is `contextvars` exists so ambient state — a request id, a tenant, a correlation token — can be read deep in a call stack without being threaded through every signature. A `contextvars.ContextVar` is not a variable in the ordinary sense; it is a **key**. The value it resolves to depends on which `contextvars.Context` is entered when you call `ContextVar.get`. A `Context` is an immutable-style mapping from ContextVar keys to values, and exactly one of them is active per thread of execution at any instant. Asyncio decides which context is active **per task**. That single design decision is the whole of this leaf. ### The snapshot happens at creation, not at first run `asyncio.create_task(coro)` captures the context that is active at the moment of the call — precisely what `contextvars.copy_context()` would return — and attaches it to the resulting `asyncio.Task`. From then on the loop enters that context every time it steps the coroutine: the first line, and each resumption after every `await`. `asyncio.TaskGroup.create_task` does the same, so children of a task group inherit the context that was active in the group's body when each child was created. Because the capture is a copy of the mapping and not a shared handle on it, a write inside the task is invisible outside it. `ContextVar.set` rebinds the key in whichever context is currently entered, and inside a task that is the task's private copy. The parent's mapping is untouched; so is every sibling's, since each sibling got its own copy. So the propagation rule has three parts worth stating explicitly in an interview: 1. **One-way.** Parent to child, never child to parent. 2. **One-shot.** At creation time only; a later `set` in the parent does not reach an already-created child. 3. **Not sideways.** Two tasks created from the same parent share the parent's *values*, but not a mapping — neither can see the other's later writes. ### Why the copy is the right default An event loop interleaves many tasks on one thread, and every `await` is a possible suspension point. If all tasks shared one mapping, another task's `set` could land between your own `set` and your `get` — the ambient state would be as unreliable as a module-level global under interleaving. Copying at creation gives each logical unit of work a stable, private view that survives arbitrary interleaving, which is exactly what a correlation id needs. ### Getting a value back out Since a ContextVar write cannot escape upward, use one of these instead: * **Return it** from the coroutine and read it from the task's result. This is almost always the right answer. * **Write it through an object the parent holds** — a list, a dict, a small result holder passed as an argument. * **Put a mutable container in the ContextVar before the tasks start.** The *binding* is copied, but the object it points at is shared, so a dict placed in a ContextVar in the parent is visible and mutable from every child. This works, and it is a footgun: you have just reintroduced shared mutable state, so it needs the same care as any other. ### Controlling which context a task gets Since Python 3.11, `asyncio.create_task` accepts a `context` keyword: pass a `contextvars.Context` from `contextvars.copy_context()` and the task runs in that one instead of the caller's current snapshot. Since 3.12, `asyncio.Task.get_context()` hands back the context object a task is running in, which is useful in a custom task factory or when instrumenting a running loop. ### What interviewers listen for The weak answer is *ContextVars are like globals but for async*. The strong answer names the copy, names when it is taken, and says out loud that the direction is parent-to-child-only — then reaches for a return value rather than trying to make a ContextVar carry data upward. ### The copy is shallow, and that matters Copying a context copies the **bindings**, not the objects behind them. If a ContextVar held a dict when the task was created, the task's copy points at the very same dict, and mutating it is visible to the creator and to every sibling. Rebinding the ContextVar to a different dict is private; mutating the one already there is not. That single distinction explains most of the surprised faces this topic produces: the isolation is over the mapping, not over the data. It also gives you the honest way to describe the guarantee. A ContextVar protects you from *another task rebinding the name out from under you*. It does not turn whatever you stored into thread-safe or task-safe data. If you deliberately place a mutable accumulator in a ContextVar so children can contribute to it, that is a shared-state design with the usual obligations, and it should be written down as one rather than discovered later. ### A fast way to check your understanding Predict the output before running it: set a var to `A`, create a task that awaits once and then reads the var, set the var to `B` in the parent, then await the task. The task prints `A`, because its snapshot was taken while the value was still `A` — the later `B` never crosses the boundary. If you predicted `B`, you are still picturing a shared mapping that the task reads at run time, and that is the model worth correcting before an interview.

  • Do two tasks created from the same coroutine share anything through their contexts?
    They share the values that existed at creation, because each got a copy of the same mapping. They do not share the mapping itself, so a later `ContextVar.set` in one is invisible to the other. If both contexts hold the same mutable object — a dict, say — they do share that object, because copying the context copies the binding, not the value.
  • How would you make a task run in a context you built yourself rather than the caller's?
    Build one with `contextvars.copy_context()`, populate it by calling `Context.run` with `ContextVar.set` and the value, then pass it as the `context` keyword to `asyncio.create_task`. That keyword has been available since Python 3.11. `asyncio.TaskGroup.create_task` accepts it too, so a group child can be given a prepared context the same way.
  • If a task never awaits, does it still get its own context copy?
    Yes. The copy is taken when the task is created, not when it first suspends, and it is entered for every step of the coroutine regardless of whether the coroutine ever yields. A task that runs straight through to completion still writes into its private copy, so its `ContextVar.set` calls remain invisible to the creator.

Creating a task is like handing someone a photocopy of your notes as they leave the room: they can read everything you had written by then, but whatever they scribble on their copy never appears on yours.

saying these in an interview costs you the question

  • Calls ContextVars global variables that async code can share
  • Believes a task writes into the parent's context
  • Thinks the copy is taken when the task first runs
  • Expects sibling tasks to see each other's writes
  • Uses a ContextVar to return a result from a task
  • Assumes TaskGroup children share one context

context