---
title: "Python asyncio — ContextVar Does Not Propagate Across Task Boundaries"
aliases: [asyncio-contextvar, python-contextvar-wait-for, contextvar-task-boundary]
tags: [python, asyncio, concurrency, debugging, fastapi, gotcha]
sources:
  - "daily/2026-04-27.md"
created: 2026-04-27
updated: 2026-04-27
---

# Python asyncio — ContextVar Does Not Propagate Across Task Boundaries

Python's `contextvars.ContextVar` is copied at task creation time — the copy is a snapshot. Changes made to a `ContextVar` in a parent coroutine after a child task is spawned are NOT visible to the child. More critically, `asyncio.wait_for()`, `asyncio.create_task()`, and `asyncio.ensure_future()` all create a new execution context — any `ContextVar` set in the calling coroutine before spawning is visible (it was in the snapshot), but the pattern of setting the var in a middleware and reading it deep inside a task is fragile because the snapshot timing matters.

In practice, the failure mode is: a FastAPI middleware sets `current_user_ctx.set(user)`, but a background task invoked via `asyncio.wait_for()` reads `current_user_ctx.get()` and gets the default value (`""`) instead. The bug is silent — no exception, just empty string in cost tracker events or missing audit logs.

## Key Points

- **`ContextVar` is snapshot-based**: when a task is created, it inherits a copy of the current context — changes to the original context after that point are invisible
- **`asyncio.wait_for()` creates a new task**, so the same propagation rules apply — do NOT rely on ContextVar being readable inside `wait_for`-wrapped coroutines
- **The failure is silent**: `ContextVar.get()` returns the default value (`""`, `None`) without raising — the app continues working with empty/wrong user identity
- **The fix is always the same**: pass the value as an explicit function parameter — `user_id: str` all the way down the call chain
- Applies to any ContextVar use: current user, request ID, tenant ID, tracing spans

## Details

### How the Bug Manifests

```python
# ❌ BROKEN — ContextVar + asyncio.wait_for
_user_ctx: ContextVar[str] = ContextVar("user", default="")

async def process_request(user_id: str, prompt: str):
    _user_ctx.set(user_id)                          # set in this coroutine's context
    result = await asyncio.wait_for(               # creates a new task
        call_ai_with_tracking(prompt),             # ← _user_ctx.get() == "" here
        timeout=300,
    )
    return result

async def call_ai_with_tracking(prompt: str):
    user = _user_ctx.get()    # returns "" — the snapshot didn't include .set() above
    await record(user_external_id=user, ...)
```

### The Fix: Explicit Parameters

```python
# ✅ CORRECT — explicit parameter
async def process_request(user_id: str, prompt: str):
    result = await asyncio.wait_for(
        call_ai_with_tracking(prompt, user_id=user_id),  # pass explicitly
        timeout=300,
    )
    return result

async def call_ai_with_tracking(prompt: str, user_id: str):
    await record(user_external_id=user_id, ...)   # always available
```

This pattern must be propagated down the entire call chain — every function between `process_request` and `record()` needs to accept and forward `user_id`.

### When ContextVar DOES Work

ContextVar is reliable in the main request coroutine and synchronous call stack:

```python
# ✅ WORKS — same coroutine, no task boundary
async def process_request(user_id: str):
    _user_ctx.set(user_id)
    result = await call_directly(prompt)   # NOT wait_for, NOT create_task
    return result

async def call_directly(prompt: str):
    user = _user_ctx.get()    # works — same context chain, no task creation
```

ContextVar also works for FastAPI request-scope middleware that sets a var, then reads it in the same request handler — as long as no background tasks are spawned within that request.

### Task Boundaries That Break ContextVar

| API | Creates new context | ContextVar broken |
|-----|--------------------|--------------------|
| `await coroutine()` | No | No |
| `asyncio.wait_for(coro, timeout)` | Yes | **Yes** |
| `asyncio.create_task(coro)` | Yes | **Yes** |
| `asyncio.ensure_future(coro)` | Yes | **Yes** |
| `asyncio.gather(*coros)` | Yes (each) | **Yes** |
| `loop.run_in_executor(fn)` | Yes | **Yes** |

### Real Incident (2026-04-27)

NotebookLM (FastAPI backend): a `ContextVar` set in the route handler with `set_user_ctx(user.email)` was read inside `asyncio.wait_for(generate_notebook(...), timeout=300)`. The `_user` ContextVar returned `""` inside the task — cost tracker events were recorded with `user_external_id=""`.

Fix: removed `ContextVar` entirely. Changed `generate_notebook(prompt)` to `generate_notebook(prompt, user_external_id=user.email)`. All callers updated to pass the value explicitly.

### LlamaIndex Note

LlamaIndex callbacks and event system also use `ContextVar` internally. If wrapping LlamaIndex calls with `asyncio.wait_for`, trace context may be lost. Additionally, `extract_llama_tokens()` helpers can return `(0, 0)` — always add a fallback:

```python
input_tok, output_tok = extract_llama_tokens(response) or (len(prompt) // 4, 200)
```

## Related Concepts

- [[wiki/tech-patterns/cost-tracker-integration]] — cost tracker integration where this bug was discovered; Step 4 documents the explicit-parameter pattern
- [[wiki/concepts/preflight-record-pattern]] — the preflight/record calls that received empty `user_external_id` due to this bug
- [[wiki/tech-patterns/python-ai-agents]] — Python AI agent patterns; explicit parameter passing applies throughout

## Sources

- [[daily/2026-04-27.md]] — NotebookLM cost tracker integration: `_user` ContextVar returned `""` inside `asyncio.wait_for`-wrapped notebook generation; confirmed same issue in video-accessibility; fix was explicit `user_external_id` parameter throughout call chain
