contextvars mutated in sync route handler are not visible to BackgroundTasks #3349
Replies: 1 comment
|
Your trace is right, and this is expected rather than something Starlette can quietly fix. Each Starlette can't really bridge this without threading a shared context object between the two The fix is to not rely on the contextvar crossing that boundary. Read what you need in the handler and hand it to the task: def sync_endpoint(request):
ctx_var.set("test_value")
value = ctx_var.get()
return JSONResponse(
{"route": "sync"},
background=BackgroundTask(run_background_task, value),
)If the task genuinely needs the contextvar set (some library downstream reads it), capture it and re-set it inside the task. I ran your repro with this and the task sees def sync_endpoint(request):
ctx_var.set("test_value")
captured = ctx_var.get()
def task():
ctx_var.set(captured)
run_background_task()
return JSONResponse({"route": "sync"}, background=BackgroundTask(task))Passing the value explicitly is the cleaner of the two. The contextvar was only ever an implicit channel here, and implicit channels don't survive a thread copy. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Describe the bug
When a contextvar is mutated inside a sync route handler, that mutation
is not visible inside a
BackgroundTaskscheduled from the same handler.The equivalent async route does not have this problem.
This is pure Starlette behavior — no FastAPI, no third-party instrumentation
involved.
Root cause (verified against source)
Both the sync route handler and a sync
BackgroundTaskare executed viastarlette.concurrency.run_in_threadpool, which simply delegates toanyio.to_thread.run_sync→run_sync_in_worker_thread.Looking at anyio's asyncio backend implementation
(
anyio/_backends/_asyncio.py), each call torun_sync_in_worker_threaddoes:
This takes a fresh
copy_context()snapshot of the calling thread'scontext on every individual call, runs
funcinside that copied context ina worker thread, and there is no code path that writes the worker thread's
(possibly mutated) context back into the caller's context. The
futureonly carries
func's return value, not the context.This isolation is presumably intentional in anyio (a general-purpose
threading primitive shouldn't silently leak mutations back to the caller).
The issue is that Starlette calls
run_in_threadpooltwice, independentlyfor a single conceptual request — once for the sync route handler, once
later for the sync
BackgroundTask— and does nothing to bridge contextbetween those two calls. Each call gets its own fresh snapshot of the
original (handler-unmutated) context, so any mutation made inside the
handler's worker thread is invisible to the background task's worker thread.
Async routes don't hit this because no thread hop (and thus no
copy_context()boundary) occurs.Versions used to trace this: starlette 1.3.1, anyio 4.12.1, Python 3.13.13.
Minimal repro
```python
import contextvars
from starlette.applications import Starlette
from starlette.background import BackgroundTask
from starlette.responses import JSONResponse
from starlette.routing import Route
ctx_var = contextvars.ContextVar("test_key", default=None)
def run_background_task():
print(f"[background task] test_key = {ctx_var.get()}")
def sync_endpoint(request):
ctx_var.set("test_value")
return JSONResponse(
{"route": "sync"}, background=BackgroundTask(run_background_task)
)
async def async_endpoint(request):
ctx_var.set("test_value")
return JSONResponse(
{"route": "async"}, background=BackgroundTask(run_background_task)
)
app = Starlette(
routes=[
Route("/sync", sync_endpoint),
Route("/async", async_endpoint),
]
)
```
Run with
uvicorn repro:app, then:```
curl http://localhost:8000/sync
curl http://localhost:8000/async
```
Actual output
```
[sync handler] before set: test_key = None
[sync handler] after set: test_key = test_value
[background task] test_key = None <-- mutation lost
[async handler] before set: test_key = None
[async handler] after set: test_key = test_value
[background task] test_key = test_value <-- mutation preserved
```
Expected behavior
Since anyio's per-call context isolation appears intentional, the fix
likely belongs in Starlette — e.g. capturing the context resulting from
the handler's
run_in_threadpoolcall and explicitly passing it into thesubsequent
BackgroundTaskexecution, rather than relying on eachrun_in_threadpoolcall to share ambient context implicitly.Versions
starlette: 1.3.1
Python: 3.13.13
OS: macOS
Additional context
This was originally surfaced via OpenTelemetry's FastAPI instrumentation
(open-telemetry/opentelemetry-python-contrib#3586), where users observed
spans/context set during a sync request handler not propagating to
background tasks. A maintainer there asked whether this is more correctly a
Starlette-level issue — filing here to get that confirmed and to see if a
fix at this layer (e.g. capturing context after the threadpool call returns,
before scheduling background tasks) would be the right place to address it.
Related OTel PR (workaround at the instrumentation layer, pending discussion
on whether it should live there or here):
open-telemetry/opentelemetry-python-contrib#4724
All reactions