Passing an Explicit Context to create_task¶
By default, asyncio.create_task copies the caller's context, so a task sees every context variable the caller had set — request ID, user, tenant, deadline. That is right for work that belongs to the request and wrong for work that outlives it. Since Python 3.11, create_task, TaskGroup.create_task, loop.call_soon and loop.call_later accept a context= argument that replaces the copy. Measured on Python 3.14 with a handler that set a request ID and a 100 ms deadline in context and then started a background job needing 0.5 s: the job that inherited the context failed with TimeoutError after 0.10 s, cut off by a deadline that belonged to the request. The same job started with context=contextvars.Context() completed in 0.50 s but had lost the request ID for its logs. Started with a context prepared to carry only the request ID, it completed and still logged req-42. Two tasks given the same Context object shared their changes — each incremented a counter 1,000 times and the shared value ended at 2,000 — while the caller still saw 0. This guide shows how to choose what each task inherits.
Prerequisites¶
- Python 3.11+ for the
context=arguments. - Deadline propagation, from propagating deadlines with contextvars.
- The topic overview, Context Variables & Request Context.
1. See what a task inherits by default¶
A task created inside a request handler inherits everything the handler set. For a deadline carried in context, that includes the request's time budget:
request_id = contextvars.ContextVar("request_id", default=None)
deadline = contextvars.ContextVar("deadline", default=None) # absolute loop time
async def call_with_deadline(seconds: float):
loop = asyncio.get_running_loop()
d = deadline.get()
budget = 10.0 if d is None else d - loop.time() # request budget, if any
async with asyncio.timeout(budget):
await do_work(seconds)
async def handler(request):
request_id.set(request.headers["x-request-id"])
deadline.set(asyncio.get_running_loop().time() + 0.1) # 100 ms for this request
asyncio.create_task(rebuild_search_index()) # inherits both
return Response(status=202)
Measured: the background job, which needed 0.5 s, raised TimeoutError after 0.10 s — the request's deadline, copied into the task, applied to work that was never meant to finish within the request. The same mechanism carries a request's database session, user identity or tracing span into tasks that outlive the request, as described in avoiding ContextVar leaks in background tasks. The default is a copy, not a link: changes the task makes later are invisible to the handler, and vice versa.
Verify: for each task your request handlers create, you know which context variables it inherits and whether it should.
2. Start detached work in an empty context¶
For work that is not part of the request — cache warming, index rebuilds, fire-and-forget notifications handed to a background runner — start it with an empty context:
asyncio.create_task(rebuild_search_index(), context=contextvars.Context())
Measured: the job completed in 0.50 s, unaffected by the request's deadline. It also saw request_id as None, so its log lines no longer said which request triggered it. An empty context is the safe default for detached work: it cannot inherit a deadline, a session, a tenant or a large object by accident. Anything the job needs should be passed as an argument or set deliberately, as in the next step.
Verify: background jobs started from request handlers run with no request-scoped variables set.
3. Prepare a context that carries only what the task needs¶
Often a detached task should keep some request context — the request ID for log correlation, a trace link — and drop the rest. Build a new context, set exactly those variables inside it with Context.run, and pass it:
def detached_context(*carry: contextvars.ContextVar) -> contextvars.Context:
ctx = contextvars.Context()
for var in carry:
value = var.get(None)
if value is not None:
ctx.run(var.set, value) # set inside the new context
return ctx
asyncio.create_task(
rebuild_search_index(),
context=detached_context(request_id), # keep the id, drop the deadline
)
Measured: the job completed in 0.50 s and logged req-42. ctx.run(var.set, value) is the way to set a variable in a context that is not the current one; calling var.set() directly would change the handler's own context instead. A context cannot be entered twice at once — calling ctx.run(...) from inside a task already running in ctx raised RuntimeError: cannot enter context: ... is already entered — so prepare contexts before handing them to tasks, not from within them.
Verify: detached tasks see the variables you chose to carry and none of the others.
4. Share one context between tasks only on purpose¶
Passing the same Context object to several tasks makes them share it: a variable set by one is visible to the others, because they all run in that one context rather than in copies:
counter = contextvars.ContextVar("counter", default=0)
shared = contextvars.copy_context()
async def bump(n):
for _ in range(n):
counter.set(counter.get() + 1)
await asyncio.sleep(0)
await asyncio.gather(
asyncio.create_task(bump(1000), context=shared),
asyncio.create_task(bump(1000), context=shared),
)
shared.run(counter.get) # 2000
counter.get() # 0 in the caller's own context
Measured: the shared value ended at 2,000 and the caller still saw 0. This works because a context is only entered for the duration of one task step, and steps never overlap on one loop — but it turns context variables into shared mutable state between tasks, with all the reasoning about interleaving that implies. It is useful for a group of tasks that should behave as one unit, such as workers updating a shared request-scoped trace; for anything else, separate copies are easier to reason about.
Verify: any context object passed to more than one task is documented as shared, and the variables in it are safe to update from several tasks.
5. Use the same argument for callbacks and task groups¶
The context= argument is not limited to create_task. TaskGroup.create_task, loop.call_soon, loop.call_soon_threadsafe and loop.call_later accept it too:
ctx = detached_context(request_id)
loop.call_soon(flush_metrics, context=ctx)
loop.call_later(30, expire_session, session_id, context=ctx)
async with asyncio.TaskGroup() as tg:
tg.create_task(fan_out_part(1), context=ctx)
Measured: with the caller's variable set to "caller" and a prepared context holding "explicit", call_soon without context= saw "caller", while call_soon and call_later with context=ctx saw "explicit"; a task created in a TaskGroup with context=ctx reported task.get_context() is ctx as True. Timers set during a request are a common place for request context to leak into work that runs much later, and passing an explicit context closes it. For callbacks scheduled by libraries that do not accept a context, wrap the callable with ctx.run, as in running callbacks in a copied context.
Verify: timers and callbacks scheduled from request handlers specify their context explicitly.
Verification¶
Tasks inherit the right context when:
- Request-scoped work uses the default copy, and detached work never does.
- Detached tasks start in an empty or prepared context, carrying only chosen identifiers.
- Shared contexts are deliberate and documented.
- Timers and callbacks scheduled from handlers pass
context=explicitly.
Diagnostic Hook: log task.get_context() contents — at least the presence of request-scoped variables — when long-lived tasks start. A background task logging a request ID hours after that request finished, or failing with timeouts that match a request's budget, has inherited a context it should not have.
Pitfalls & edge cases¶
- Background work inheriting a request deadline. Measured:
TimeoutErrorafter 0.10 s for a 0.5 s job. - Empty contexts that drop log correlation. Carry the request ID deliberately.
- Calling
var.set()to prepare a context. It changes the current context; usectx.run(var.set, value). - Entering a context that a running task uses. It raised
RuntimeError.
Frequently Asked Questions¶
What does the context argument of asyncio.create_task do?
It replaces the default copy of the caller's context with the Context you pass. An empty Context() gives the task no inherited variables; a prepared one gives it exactly the variables you set in it.
Why does my background task time out with the request's deadline?
It inherited a deadline stored in a context variable: a 0.5 s job started from a request with a 100 ms budget failed after 0.10 s. Start it with context=contextvars.Context().
How do I keep the request ID but drop other context?
Create a new Context, set the request ID in it with ctx.run(request_id.set, value), and pass it as context=; the job kept req-42 and ignored the deadline.
Can two tasks share the same context?
Yes, by passing the same Context object: changes in one are visible in the other (two tasks incrementing 1,000 times each reached 2,000). Use it only on purpose.
Related¶
- Context Variables & Request Context — up to the topic overview.
- Scoping tenant data with contextvars — a context variable that must never leak.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.