Why doesn't a contextvars.ContextVar set before ThreadPoolExecutor.submit reach the worker, and what did 3.14 add?
answer
- Two different mechanisms, often confused
- The queue carries the callable, not the context
- Fires at thread start, not per task
- A lazily created worker captures one submitter
- Snapshot at submit, run inside the copy
basics
~10 sconcurrent.futures.ThreadPoolExecutor never copies the submitting thread's context onto a work item, so the worker reads defaults. Python 3.14 added -X thread_inherit_context and PYTHON_THREAD_INHERIT_CONTEXT, but they only apply when a threading.Thread is started.
solid answer
~40 sContext lives in a context object, and nothing on the executor path copies it: `submit` queues the callable and a long-lived worker runs it, so a `ContextVar` set by the submitter reads its default inside the task. Python 3.14 added `-X thread_inherit_context` (and `PYTHON_THREAD_INHERIT_CONTEXT`) so a thread started with `threading.Thread` copies the context of the caller of `start` — off by default on the standard build, on for free-threaded builds. That flag does **not** fix a pool: workers are started lazily, so a worker captures the context of whichever submitter happened to create it and keeps it for every later task, which is worse than a uniform default. The explicit fix is per submission: `ctx = contextvars.copy_context()` then `pool.submit(ctx.run, fn, *args)`. `asyncio.to_thread` already does this; `loop.run_in_executor` does not.
code
python · 13 linesimport contextvars
from concurrent.futures import ThreadPoolExecutor
number_format = contextvars.ContextVar("number_format", default="C")
number_format.set("de_DE")
def render():
return number_format.get()
with ThreadPoolExecutor(max_workers=1) as pool:
print("plain submit:", pool.submit(render).result())
ctx = contextvars.copy_context()
print("via Context.run:", pool.submit(ctx.run, render).result())go deeper
Recall the plain fact that a thread you hand work to does not automatically see values set in the thread that handed it over, and that something has to carry them across explicitly.
Be able to explain the two mechanisms separately: what a context is, that a thread historically started empty, and that copy_context plus Context.run is how you move a snapshot across a boundary.
An interviewer expects the failure analysis: why the 3.14 flag is a thread-start feature rather than a per-task one, why a lazily created pool worker captures one submitter, and how the wrong output looks plausible instead of crashing.
Own the propagation policy across services: whether ambient context crosses thread boundaries at all, whether an interpreter-level flag may be load-bearing when it is invisible in source, and how you keep the rule uniform across every hand-off point.
Two separate mechanisms are involved, and mixing them up is what makes this bug so persistent. ## Mechanism one: a new thread starts with an empty context `contextvars` values are stored in a *context*, and until Python 3.14 a thread you started never received a copy of anyone's context — it began with an empty one, so every `ContextVar` read fell back to its default (or raised if there is no default). Nothing about `threading` propagated context. Python 3.14 made that configurable. The `-X thread_inherit_context` command-line option and the matching `PYTHON_THREAD_INHERIT_CONTEXT` environment variable, when enabled, cause a thread created with `threading.Thread` to start with a *copy* of the context of whichever thread called that thread's `start` method. The default depends on the build: the standard interpreter leaves it off, and free-threaded builds turn it on, since that is the configuration the option was designed for. ## Mechanism two: the executor never copies anything `concurrent.futures.ThreadPoolExecutor.submit` does not touch contexts at all. It wraps your callable and its arguments in a work item, puts it on a queue, and a worker eventually runs it. There is no snapshot of the submitting thread's context anywhere in that path, in 3.14 or before. So on a default 3.14 interpreter, the worker runs with an empty context and your `ContextVar` reads its default. ## Why turning the flag on is not the fix Here is the part that separates a rehearsed answer from a real one. The flag applies at `Thread.start`, and a pool's worker threads are started lazily by whichever `submit` call first needed a new worker. With the flag on, that worker therefore captures **the context of the submitter that happened to create it** — and keeps it for the rest of the pool's life. Every later task on that worker, submitted by anyone, runs with that first submitter's values. Consider a sensor-telemetry collector that keeps each device's locale-dependent number format in a `ContextVar` and renders readings on a shared pool. With the flag off, every reading renders with the default format — wrong, but uniformly and visibly wrong. With the flag on, readings render with the format belonging to whichever device happened to warm that worker up, which is plausible, per-worker, and effectively random. It is exactly the shape of defect that ships through three release trains before someone trusts the complaint. So: the flag is useful for plain `threading.Thread` hand-offs, and it is not a substitute for explicit propagation across a pool. ## The explicit route, which is the answer Snapshot the context at submit time and run the callable inside it: ```python ctx = contextvars.copy_context() future = pool.submit(ctx.run, render, reading) ``` `contextvars.copy_context()` takes a shallow copy of the *calling* thread's context — the correct one, captured at the correct moment — and `Context.run` executes the callable with that context active. Two properties make this the right primitive: the copy is per submission, so nothing persists on the worker for the next task; and because it is a copy, a `ContextVar.set` inside the task does not propagate back to the submitter. If you need a value back, return it from the callable. The stdlib already does this for you in one place: `asyncio.to_thread` copies the current context and runs the function inside it, so context survives that hand-off. `loop.run_in_executor` does not, which is a genuinely useful pair of facts to have straight — two adjacent APIs for "run this on a thread" with opposite context behaviour. ## Operating it * Decide once, centrally. Wrap submission in a helper that always copies the context, rather than leaving each call site to remember. * Make the failure loud. If the value is required, read it without a default so a missing context raises at the boundary instead of silently rendering with a fallback. * Treat the interpreter flag as a deployment-level setting, not a code fix. It changes behaviour for the whole process, it is invisible in the source, and a second entry point launched without it behaves differently. If you rely on it, pin it in the process launcher and say so in the code that depends on it. * Do not reach for `threading.local` here. It is keyed by the worker thread, so it is subject to the same first-submitter capture problem in a more permanent form, plus the staleness that thread reuse brings. The one-sentence version for an interview: **thread hand-offs do not carry context unless something copies it — the 3.14 switch covers `Thread.start`, the executor covers nothing, and `copy_context` plus `Context.run` is the explicit, per-task mechanism that actually holds.**
- Why can enabling -X thread_inherit_context make a pooled workload behave worse rather than better?Because it applies at thread start, and a pool starts workers lazily on demand. A worker therefore inherits the context of whichever submitter triggered its creation and keeps it for the pool's lifetime, so later tasks from other submitters silently run with a stranger's values. A uniform default is at least consistently wrong and easy to spot.
- If a task calls ContextVar.set inside a copied context, does the submitting thread see the change?No. `contextvars.copy_context()` returns a copy, and `Context.run` activates that copy for the callable, so any set inside affects only the copy. That one-way isolation is the property you want for a pool — nothing leaks back to the submitter and nothing persists on the worker. Return the value from the callable if the caller needs it.
- Which stdlib API for running a function on a thread already propagates context, and which does not?`asyncio.to_thread` copies the current context and runs the function inside it, so a ContextVar set before the await is visible in the function. `loop.run_in_executor` does not copy anything, so the same function reads defaults there. Two adjacent APIs, opposite behaviour — worth checking before assuming either way.
saying these in an interview costs you the question
- Says ThreadPoolExecutor.submit copies the caller's context
- Claims -X thread_inherit_context is on by default everywhere
- Thinks the 3.14 flag propagates context per submitted task
- Reaches for threading.local as the propagation fix
- Expects a set inside a copied context to reach the submitter
- Assumes run_in_executor behaves like asyncio.to_thread