Losing the request id half-way through a request is the difference between a
five-minute log search and an afternoon. Sharing a context where you expected
isolation is a data-exposure incident. contextvars is the module that decides
which of those you get, and the rules differ per mechanism in ways that are not
guessable.
The primitive
from contextvars import ContextVar
request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
token = request_id.set("abc")
try:
...
finally:
request_id.reset(token)
set() returns a Token[T], and reset(token) restores the previous
value — which is emphatically not the same as setting None. Nested scopes
have to unwind to whatever they shadowed, and only the token knows what that
was.
This is why the correct shape is always a context manager with a finally. A
bare set() at the top of a handler leaks the value into whatever runs next on
that task — which, in a long-lived worker, is the next request.
Propagation, by mechanism
This is the table worth memorising, because every row is different:
| Mechanism | Propagates? | Notes |
|---|---|---|
asyncio.create_task / TaskGroup.create_task |
copies the creating context |
changes inside the child are invisible to the parent; context= since 3.11 |
await coro (no task) |
same context | it is the same task |
asyncio.to_thread |
yes, explicitly | documented behaviour |
loop.run_in_executor |
no |
pass the context yourself, or use to_thread |
bare threading.Thread |
historically no | see below |
concurrent.futures submit |
no |
contextvars.copy_context().run(fn) is the manual fix |
The task row is the one that surprises people in both directions. A task copies the context, so a value you set inside a child task does not leak back to the parent — good, that is isolation. But it also means a child task cannot communicate upward through a ContextVar, which people occasionally try.
The 3.14 row that will bite
threading.Thread gained thread_inherit_context in 3.14. Its default is
True on free-threaded builds and False on GIL builds.
Read that again, because it is unusual: the same Python version, the same
source code, different propagation depending on which build you are running.
A service that works on python3.14 and loses its request id on python3.14t
— or the reverse — is behaving exactly as documented.
The defence is to be explicit rather than to rely on the default:
threading.Thread(target=fn, context=contextvars.copy_context()) says what you
mean on every build.
💡A logging filter reads the request id and attaches it to every record. A background task spawned from a request handler logs an error. Does the log line carry the id? click to reveal
Yes, and that is the design working.
create_task copies the creating context at construction time, so a task
spawned inside with request_context("abc") carries "abc" for its entire
life — including after the request handler that created it has returned and
reset its own token. The reset happened in the parent’s context; the child
holds an independent copy.
Two follow-on consequences worth having thought about. First, this is a good argument for spawning background tasks inside the request scope rather than handing work to a global queue: the queue consumer runs in whatever context it started with, which is usually empty.
Second, it means a very long-lived task keeps a reference to whatever the context held when it was created. If you put something large in a ContextVar — a request body, a parsed document — it stays reachable for the life of the task. ContextVars should hold identifiers, not payloads.
Typing: ContextVar[str] versus ContextVar[str | None]
--strict forces the decision, and it is a real one.
ContextVar[str]("request_id") with no default raises LookupError on an
unset read. That makes “no request in flight” an exception rather than a value,
so every caller — including your logging filter, which runs on every log line
including startup ones — needs a try/except LookupError. Miss one and a log
call blows up in a code path that has nothing to do with requests.
ContextVar[str | None]("request_id", default=None) makes “no request” a value
you can narrow with if. That is almost always the right shape for
observability data, which is by nature sometimes absent.
Use the no-default form when absence is genuinely a bug — a tenant id in a
multi-tenant data path, say, where reading rows without one must never silently
succeed. Then the LookupError is a feature.
💡copy_context().run(fn) versus just calling fn(). When does the difference matter?
click to reveal
When fn mutates context variables and you do not want the mutation to escape.
copy_context() snapshots the current context; .run(fn) executes fn inside
that snapshot. Any set() fn performs lands in the copy and is discarded when
run returns. Calling fn() directly lets those sets persist in your context.
Two places this is the right tool. First, running untrusted or third-party
callbacks: a plugin that sets a ContextVar should not be able to change what
your code sees afterwards. Second, submitting to a concurrent.futures
executor, which does not propagate context: pool.submit(ctx.run, fn) is the
idiom that gets your request id into the worker thread — the same thing
asyncio.to_thread does for you automatically.
The asymmetry is worth noting: to_thread propagates, run_in_executor does
not, and the difference is exactly one copy_context().run that to_thread
performs on your behalf.