Skip to content

Using contextvars with Async Generators

A streaming endpoint wraps its database cursor in an async generator that sets a db_statement context variable for its query spans. After the first row is yielded, every log line in the handler — code that never touched the generator's internals — is tagged with that SQL statement. In another service, a generator that opens a tracing span with token = span.set(...) and resets it in finally blows up with ValueError: ... was created in a different Context whenever the stream is closed during shutdown. Both surprise people who assume an async generator behaves like a task with its own context. It does not. A generator's frame runs inside whichever context is current when its __anext__ is awaited — the consumer's — so anything it sets becomes visible to the consumer, and anything the consumer changes between iterations is visible to the generator. This guide demonstrates each effect, then shows the patterns that keep context use inside generators correct: read-only use, scoping around each yield, running bodies in a private context, and closing in the right task.

Prerequisites

Tasks copy context; generators share it 2 columns contrasting asyncio task, async generator. Tasks copy context; generators share it asyncio task copy at create_task own Context object its set() stays private caller changes invisible async generator runs in consumer context no Context of its own its set() leaks out caller changes visible Moving code from a task into a generator changes its context semantics.

1. See a generator's set() leak into the consumer

Each await anext(gen) resumes the generator frame in the caller's current context. A set() inside the generator therefore modifies the caller's context, and the caller sees the new value after the await returns.

import asyncio
import contextvars

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


async def rows():
    print("  generator starts and sees:", span.get())
    span.set("db.query")                                 # intended for the query only
    yield "row-1"
    print("  generator resumes and sees:", span.get())
    yield "row-2"


async def handler() -> None:
    span.set("http.request")
    stream = rows()
    await anext(stream)
    print("handler after first row sees:", span.get())   # db.query: leaked
    span.set("http.request.render")
    await anext(stream)                                    # generator sees the handler's change


asyncio.run(handler())

The handler's span becomes db.query after the first row, and the generator, when resumed, sees the handler's http.request.render. Context flows both ways because there is only one context: the task's. PEP 568 once proposed per-generator contexts, but it was deferred, so this behaviour is the standard one.

Verify: the output shows the handler reading db.query after the first anext, and the generator reading http.request.render on resume.

2. Scope values around each yield instead of across it

If a generator needs a value set while it does its own work, set it and reset it before yielding, so the consumer never runs with it. The token is created and used within one __anext__ call, in one context.

import asyncio
import contextlib
import contextvars

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


@contextlib.contextmanager
def scoped(var: contextvars.ContextVar, value):
    token = var.set(value)
    try:
        yield
    finally:
        var.reset(token)


async def fetch_batch(n: int) -> list[str]:
    await asyncio.sleep(0)
    return [f"row-{n}-{i}" for i in range(2)]


async def rows():
    for n in range(2):
        with scoped(span, "db.query"):                   # only while the generator works
            batch = await fetch_batch(n)
            print("  fetching under span:", span.get())
        for row in batch:
            yield row                                     # yield outside the scope


async def handler() -> None:
    span.set("http.request")
    async for row in rows():
        assert span.get() == "http.request", span.get()  # the consumer's value is intact
    print("handler span after the stream:", span.get())


asyncio.run(handler())

The rule is simple to review: no yield inside a with scoped(...) block. The generator's own awaits run with the span it wants; the consumer, which runs between yields, always sees its own value.

Verify: the assertion never fires, the generator prints db.query while fetching, and the handler ends with http.request.

3. Keep tokens out of cross-task cleanup

A token must be reset in the context where it was created. A generator that sets a value before its first yield and resets it in finally works when one task drives the whole iteration — and fails when the generator is closed from another task, such as the loop's shutdown or a finaliser.

import asyncio
import contextvars

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


async def spanned_stream():
    token = span.set("stream")                           # created in the consumer's context
    try:
        while True:
            yield "event"
    finally:
        span.reset(token)                                # fails if closed from another task


async def main() -> None:
    stream = spanned_stream()
    await anext(stream)                                  # token created in main's context

    async def close_elsewhere() -> None:
        await stream.aclose()                            # finally runs in this task's context

    try:
        await asyncio.create_task(close_elsewhere())
    except ValueError as exc:
        print("reset failed:", str(exc).split(" at 0x")[0], "... was created in a different Context")
    print("main's span is still:", span.get())           # 'stream': the value leaked too


asyncio.run(main())

Two things went wrong at once: the reset raised ValueError because the closing task has a different context, and the consumer's context kept the value the generator set, since no reset ever ran there. The scoping pattern from step 2 avoids both, because no token survives across a yield. When a value genuinely must span the whole stream, set it in the consumer — around the async for — not inside the generator.

Verify: the output reports the different-context error and shows main's span stuck at stream.

A token that outlives its context 2 lanes over time. A token that outlives its context consumer task set -> token value leaked here still leaked closing task reset(token) fails time → No token should survive a yield; reset before yielding instead.

4. Give a generator a private context when it must keep state

Some generators need context that persists across their own iterations without affecting the consumer — for example a paginator that sets a per-stream correlation ID read by the HTTP client it calls. Run each step of the generator inside a dedicated Context owned by the generator's wrapper.

import asyncio
import contextvars

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


class PrivateContextStream:
    """Drive an async generator inside its own Context, isolated from the consumer."""

    def __init__(self, agen) -> None:
        self._agen = agen
        self._ctx = contextvars.Context()

    def __aiter__(self):
        return self

    async def __anext__(self):
        step = asyncio.get_running_loop().create_task(
            self._agen.__anext__(), context=self._ctx)   # generator frame runs in _ctx
        return await step

    async def aclose(self) -> None:
        await asyncio.get_running_loop().create_task(self._agen.aclose(), context=self._ctx)


async def paginate():
    stream_id.set("stream-7f3")                         # persists across this generator's yields
    for page in range(3):
        await asyncio.sleep(0)
        yield f"page {page} fetched with stream_id={stream_id.get()}"


async def main() -> None:
    stream_id.set("consumer")
    stream = PrivateContextStream(paginate())
    try:
        async for page in stream:
            print(page, "| consumer sees:", stream_id.get())
    finally:
        await stream.aclose()


asyncio.run(main())

Every page reports stream_id=stream-7f3 from inside the generator, while the consumer keeps seeing consumer. The cost is one small task per step, so reserve this for generators whose context use justifies it. Cancellation of the consumer's await cancels the step task, which throws into the generator as usual; see making async iterators cancellation-safe for what that means for iterator state.

Verify: all three pages show the private stream ID, and the consumer's value never changes.

5. Read, don't write, in shared generators

The simplest correct pattern is also the most common: generators read context values set by their consumer — request IDs for logging, deadlines for timeouts — and never set them. Reads always reflect the consumer's current context, which is usually exactly what is wanted.

import asyncio
import contextvars

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


async def events():
    for n in range(2):
        await asyncio.sleep(0)
        yield f"event {n} logged under {request_id.get()}"   # read-only use


async def consume(name: str, stream) -> list[str]:
    request_id.set(name)
    return [await anext(stream), await anext(stream)]


async def main() -> None:
    per_request = await asyncio.gather(consume("req-A", events()), consume("req-B", events()))
    print(per_request)

    shared = events()                                     # one generator, two consuming tasks
    first = await asyncio.create_task(consume_once("req-C", shared))
    second = await asyncio.create_task(consume_once("req-D", shared))
    print(first, "|", second)


async def consume_once(name: str, stream) -> str:
    request_id.set(name)
    return await anext(stream)


asyncio.run(main())

Each consumer's events carry its own request ID, and a generator shared by two tasks reports each task's ID on the step that task drove. That is correct for logging, and it is also a reminder that a generator shared between tasks has no stable context at all — another reason to give each consumer its own iterator.

Verify: req-A and req-B each appear twice in their own lists, and the shared generator yields events under req-C and then req-D.

How should this generator use context? A decision on What does the generator do with context with 3 outcomes. How should this generator use context? What does the generator do with context? only reads values read-only is fine sees the consumer sets values for its work reset before each yield scoped per step must keep values across yields private Context one task per step Setting a value and yielding with it still set is the one pattern to avoid.

Verification

Context use in async generators is correct when:

  • Generators do not leak writes: no set() inside a generator is visible to its consumer after anext returns.
  • No token spans a yield: values set by a generator are reset before it yields.
  • Stream-wide values are set by the consumer, around the async for, not inside the generator.
  • Isolation is explicit when needed: generators that must keep their own context run each step in a dedicated Context.
  • Shared generators only read context and are not driven by several tasks where context attribution matters.

Pitfalls & edge cases

  • Tracing libraries that set spans inside generators. Instrumentation that activates a span in a generator and deactivates it after the stream ends leaks the span into the consumer between yields. Prefer libraries that scope spans per step.
  • Async context managers built from generators. @asynccontextmanager deliberately lets values set before its yield be visible inside the async with block; that is the intended leak, and it resets correctly because enter and exit run in the same task.
  • Closing from the finaliser. Unclosed generators are closed by the loop in a different task, so finally blocks see a different context. Close generators explicitly in the consuming task.
  • Sync generators. The same rules apply to ordinary generators: they share the caller's context.
  • Assuming tasks and generators behave alike. Tasks copy context at creation; generators do not copy at all. Code moved from a task into a generator changes its context semantics.

Frequently Asked Questions

Do async generators have their own contextvars context?

No. An async generator's frame runs in whichever context is current when its anext is awaited, which is the consuming task's context. Values the generator sets become visible to the consumer, and values the consumer changes between iterations are visible to the generator.

Why does a ContextVar set inside my async generator appear in the calling code?

Because the generator shares the caller's context rather than having its own copy. When the generator calls set and then yields, the modified value remains in the caller's context after anext returns. Reset values before yielding, or set stream-wide values in the consumer instead.

What causes ValueError Token was created in a different Context in a generator?

The generator created a token by setting a variable during iteration in one task, and its reset ran in another task, typically because the generator was closed by the event loop's finaliser, during shutdown, or explicitly from a different task. Avoid holding tokens across yields, and close generators in the task that consumed them.

How can an async generator keep context values private?

Wrap it so each step runs in a dedicated contextvars.Context: create a Context once, and for every anext and aclose call, run the generator's method as a task created with context set to that Context. Values the generator sets then persist across its own steps without affecting the consumer.