Skip to content

Why ContextVar Changes Don't Flow Back from Tasks

Context variables have one rule that surprises almost everyone the first time: a task runs in a copy of the context it was created in. Changes the parent makes after creating the task are invisible to the child, and — the part that causes bugs — changes the child makes are invisible to the parent. A plain await some_coroutine() does not create a task, so the same set() inside an awaited helper is visible afterwards. Verified on Python 3.14: after await asyncio.create_task(child_sets()) the parent still read "parent"; after await helper_sets() it read "helper"; and a child in a TaskGroup behaved like any other task. Code that uses a context variable as a return channel — "the auth middleware sets the user, the handler reads it" — works or fails depending on whether a task boundary sits between them. This guide explains the copy semantics and the patterns that replace the return channel.

Prerequisites

1. Reproduce the three cases

import asyncio
import contextvars

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


async def child_sets() -> None:
    rid.set("child")


async def helper_sets() -> None:
    rid.set("helper")


async def main() -> None:
    rid.set("parent")
    await asyncio.create_task(child_sets())
    print(rid.get())                         # parent  — the task had its own copy

    await helper_sets()
    print(rid.get())                         # helper  — same task, same context

    rid.set("parent")
    async with asyncio.TaskGroup() as tg:
        tg.create_task(child_sets())
    print(rid.get())                         # parent  — TaskGroup children are tasks too


asyncio.run(main())

The dividing line is the task, not the await. await coro() runs the coroutine inside the current task, in the current context, so its set() changes the context the caller is using. create_task(coro()) calls contextvars.copy_context() and runs the coroutine in that copy for its whole life.

Verify: run it and get parent, helper, parent.

A task gets a copy; an awaited coroutine shares the context A sequence of 6 messages between 4 participants. A task gets a copy; an awaited coroutine shares the context parent task parent context child task copy awaited helper rid.set(parent) create_task: copy_context() rid.set(child): only in the copy rid.get() returns parent await helper_sets(): same context rid.set(helper): parent sees it Context flows down into tasks at creation and never flows back up.

2. Understand why the copy is the right design

The copy is what makes context variables safe for concurrency. Consider a server handling two requests on one loop, each in its own task. If tasks shared a context, request B's rid.set("B") would change the id that request A's log lines carry — exactly the cross-talk that thread-locals have in async code, as described in replacing thread-local state in async code.

The copy is cheap: contexts are immutable mappings with structural sharing, so copy_context() is O(1) and a set() creates a new mapping only for the task doing it. Each task's view is isolated from every other task's writes, including its parent's and children's.

The same isolation applies in the other direction for later changes: a parent that sets a variable after creating a child does not affect the child. Context is captured at creation, which is why middleware must set request state before the framework spawns the handler task — the subject of propagating request IDs with contextvars.

Verify: set a variable in the parent after create_task; the child, reading it later, sees the old value.

3. Return values from tasks, not context

When a child task computes something the parent needs, return it. The result of a task is the channel designed for upward data flow:

async def authenticate(token: str) -> User:
    claims = await verify(token)
    return await load_user(claims["sub"])


async def handle(request) -> Response:
    async with asyncio.TaskGroup() as tg:
        user_t = tg.create_task(authenticate(request.token))
        prefs_t = tg.create_task(load_prefs(request.session))
    current_user.set(user_t.result())          # set in the PARENT's context
    return await render(user_t.result(), prefs_t.result())

If the value should then be available to everything the parent calls, the parent sets the context variable itself, after collecting the result. Tasks spawned after that set() inherit it.

Verify: tasks created after the parent's set() read the user; tasks created before it do not.

Which channel carries data in which direction A grid of 4 rows by 4 columns. Which channel carries data in which direction channel direction when caution ContextVar parent to child at task creation read-only in practice task result child to parent when awaited one value asyncio.Queue either way continuously bounded size mutable object in a ContextVar both ways any time needs a lock if shared Use context for ambient, downward data; use results and queues for anything that flows back.

4. Share a mutable holder only when you mean to

Sometimes several tasks must contribute to one request-scoped thing — a list of timings, a set of cache tags, a response-header collection. The copy semantics apply to the binding, not to the object: if the context variable holds a mutable object, every task that inherited it shares that object.

from dataclasses import dataclass, field

@dataclass
class RequestState:
    cache_tags: set[str] = field(default_factory=set)
    timings: dict[str, float] = field(default_factory=dict)

state_var: contextvars.ContextVar[RequestState] = contextvars.ContextVar("state")


async def handle(request):
    state_var.set(RequestState())               # one object per request, set before fan-out
    async with asyncio.TaskGroup() as tg:
        tg.create_task(load_products())         # each child appends to the same object
        tg.create_task(load_reviews())
    response.headers["Cache-Tag"] = ",".join(state_var.get().cache_tags)


async def load_products():
    state_var.get().cache_tags.add("products")

On one event loop, plain mutations of a set or dict between awaits do not race. They do if the object is touched from executor threads, or if a mutation spans an await. Be explicit that this is a deliberate shared object, and create it per request — never set a mutable default on the ContextVar itself, or every request shares one object forever.

Verify: after the TaskGroup, the parent sees tags added by every child, and two concurrent requests see only their own tags.

5. Watch for task boundaries you did not create

The bugs come from boundaries hidden inside frameworks and libraries. Common ones:

  • Framework background tasks. FastAPI/Starlette BackgroundTasks run after the response, in a context captured when they were added — changes they make do not reach anything else.
  • asyncio.gather() with coroutines. It wraps each coroutine in a task, so set() inside them is invisible to the caller, unlike awaiting them one by one.
  • asyncio.wait_for() before 3.12 and shield(). Both wrap the awaitable in a task.
  • Middleware that spawns handler tasks. Values the handler sets are invisible to middleware code that runs after it.
async def setup_a():
    tenant.set("a")

await setup_a()                                # visible afterwards
await asyncio.gather(setup_a())                # NOT visible afterwards: gather made a task

When something "sometimes" sees a context value, look for one of these between the set() and the get(). Avoiding ContextVar leaks in background tasks covers the opposite problem — values flowing down into tasks that outlive the request.

Verify: for each place you read a context variable set elsewhere, trace the path between them and confirm there is no task creation in between.

Will the caller see this set()? A decision on How was the code that calls set() run with 3 outcomes. Will the caller see this set()? How was the code that calls set() run? await coro() directly visible to the caller same task create_task, TaskGroup, gather not visible copied context framework background task not visible captured earlier Any task boundary between set() and get() makes the change invisible.

Verification

Context usage is sound when:

  • No code relies on a child task's set() being visible to its parent.
  • Results flow back through task results or queues, and the parent sets context from them.
  • Shared mutable state in context is created per request and documented as shared.
  • Hidden task boundaries — gather, shield, background tasks — are accounted for.

Diagnostic Hook: in staging, log the context variable values at both ends of a request — set in middleware, read at response time — with the task name. A mismatch logged with a task name other than the request's own points straight at a task boundary between them.

Pitfalls & edge cases

  • Mutable default on the ContextVar. ContextVar("x", default=[]) shares one list across every request.
  • Expecting gather() to behave like sequential awaits for context changes.
  • Setting context in a done-callback. Callbacks run in the context captured at registration, so the change goes nowhere useful.
  • Thread pools. to_thread copies the context into the thread; set() inside it does not come back either.

Frequently Asked Questions

Why can't the parent see a ContextVar set in a child task?

Each task runs in a copy of the context taken when the task was created. set() inside the task changes only that copy, so the parent's context is unaffected.

Does await propagate ContextVar changes back to the caller?

Yes, when the awaited coroutine runs in the same task: await coro() shares the current context, so its set() is visible afterwards. Wrapping the coroutine in a task, gather or shield breaks that.

How do I get data back from a child task?

Return it as the task's result and read it with await or task.result(). If later code needs it as context, set the variable in the parent after collecting the result.

Can child tasks share a mutable object through a ContextVar?

Yes. The binding is copied, not the object, so tasks that inherit a context holding a dict or set all see the same object. Create it per request and be careful with mutations across awaits or threads.