Carrying contextvars Across Threads and Executors¶
Request IDs appear on every log line of a request — except the ones written by the PDF renderer, the image resizer and the legacy SDK, which all say request_id=-. Those three have one thing in common: they run in a thread. Context variables are copied automatically when asyncio creates a task, but a thread is a different execution unit with its own context, and whether it receives a copy depends entirely on how it was started. asyncio.to_thread() copies; loop.run_in_executor() does not; threading.Thread did not until Python 3.14 added an option, and that option introduces a new trap for thread pools. This guide maps each boundary, shows the per-job copy that fixes executors, explains the 3.14 thread settings, and demonstrates the stale-context bug they can cause in pooled workers.
Prerequisites¶
- Python 3.11+; step 4 needs Python 3.14 for
threading.Thread(context=...)and thethread_inherit_contextflag. Standard library only. - The context model from Context Variables & Request Context, and request-ID plumbing from propagating request IDs with contextvars.
- Thread offloading from running blocking SDK calls with asyncio.to_thread.
1. Prove which boundary drops the context¶
Before fixing anything, run the same read through every offloading path your code uses. The results are deterministic, so a short script turns a vague "IDs sometimes missing" into a precise list.
import asyncio
import contextvars
import threading
from concurrent.futures import ThreadPoolExecutor
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
def read() -> str:
return request_id.get()
async def main() -> None:
request_id.set("req-1")
loop = asyncio.get_running_loop()
pool = ThreadPoolExecutor(max_workers=2)
print("child task: ", await asyncio.create_task(asyncio.sleep(0, read())))
print("to_thread: ", await asyncio.to_thread(read)) # req-1
print("run_in_executor: ", await loop.run_in_executor(pool, read)) # -
print("pool.submit: ", pool.submit(read).result()) # -
seen: list[str] = []
t = threading.Thread(target=lambda: seen.append(read()))
t.start()
t.join()
print("threading.Thread: ", seen[0]) # - (by default)
pool.shutdown()
asyncio.run(main())
asyncio.to_thread() is implemented as "copy the current context, then run the function inside it on the default executor", which is why it behaves differently from run_in_executor() on the very same thread pool. The difference is in the call, not the threads.
Verify: the output shows req-1 for the task and to_thread, and - for run_in_executor, pool.submit and the raw thread. Any production call site using one of the - paths is a source of unattributed logs.
2. Prefer to_thread for one-off blocking calls¶
Where the call site is yours and the executor does not matter, switch to asyncio.to_thread(). It uses the loop's default executor, copies context per call, and reads more clearly.
import asyncio
import logging
log = logging.getLogger("render")
def render_invoice(order_id: str) -> bytes:
log.info("rendering %s", order_id) # carries request_id via the logging filter
return b"%PDF-..."
async def invoice_endpoint(order_id: str) -> bytes:
# before: await loop.run_in_executor(None, render_invoice, order_id)
return await asyncio.to_thread(render_invoice, order_id)
Keep in mind that to_thread() always uses the default executor. If you need a dedicated, separately sized pool — for example to keep slow renders from starving other blocking calls, as in sizing worker pools for mixed workloads — use the explicit copy in step 3 instead.
Verify: the log line from render_invoice now carries the request's ID instead of -.
3. Copy the context per job for custom executors¶
For a specific executor, capture the context at submission time and submit ctx.run(fn, *args) as the job. Copy once per job: a Context object can be entered by only one thread at a time, and sharing one copy between concurrent jobs raises RuntimeError.
import asyncio
import contextvars
import functools
from concurrent.futures import Executor, ThreadPoolExecutor
async def run_with_context(executor: Executor, fn, /, *args, **kwargs):
loop = asyncio.get_running_loop()
ctx = contextvars.copy_context() # fresh copy for this job
job = functools.partial(ctx.run, fn, *args, **kwargs)
return await loop.run_in_executor(executor, job)
def submit_with_context(executor: Executor, fn, /, *args, **kwargs):
"""Same idea for synchronous callers that use executor.submit()."""
ctx = contextvars.copy_context()
return executor.submit(ctx.run, fn, *args, **kwargs)
RENDER_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="render")
Two properties follow from Context.run. Values the job sets stay inside that job's copy and never leak back to the request or into the next job on the same worker thread. And because the copy is taken at submission, a job queued behind others still sees the context of the request that submitted it, not whatever happens to be current when a worker picks it up.
Verify: submit the same slow function twice concurrently with a shared captured context and the second job fails with RuntimeError: cannot enter context ... is already entered; with run_with_context both succeed and both read the submitting request's ID.
4. Use Python 3.14's thread context options deliberately¶
Python 3.14 adds two related controls. threading.Thread accepts a context= argument: the thread's run() executes inside that context. And the thread_inherit_context flag — set with -X thread_inherit_context=1 or PYTHON_THREAD_INHERIT_CONTEXT=1, and enabled by default on free-threaded builds — makes every new thread start with a copy of the context of the thread that started it.
import contextvars
import threading
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
def start_worker_for_request(target, *args) -> threading.Thread:
"""Python 3.14+: a dedicated thread that runs in the caller's request context."""
t = threading.Thread(target=target, args=args,
context=contextvars.copy_context(), daemon=True)
t.start()
return t
def start_detached_worker(target, *args) -> threading.Thread:
"""A long-lived thread that must not inherit any request's values."""
t = threading.Thread(target=target, args=args, context=contextvars.Context(), daemon=True)
t.start()
return t
Passing context= explicitly makes the intent visible and behaves identically whether or not the inherit flag is on, which matters because the flag's default differs between the regular and free-threaded builds.
Verify: on Python 3.14, sys.flags.thread_inherit_context reports 0 on the regular build and 1 on the free-threaded build; threads started with the helpers above read the expected values on both.
5. Guard thread pools against stale inherited context¶
The inherit flag applies when a thread is created, and pool threads are created lazily and then reused. With the flag on, a worker thread inherits the context of whichever request happened to trigger its creation, and every later job submitted with a plain submit() runs in that stale context.
# run with: python3 -X thread_inherit_context=1 stale_pool.py
import contextvars
from concurrent.futures import ThreadPoolExecutor
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
pool = ThreadPoolExecutor(max_workers=1)
def submit_as(rid: str) -> str:
request_id.set(rid)
return pool.submit(request_id.get).result()
print(contextvars.Context().run(submit_as, "req-A")) # req-A: worker created now, inherits A
print(contextvars.Context().run(submit_as, "req-B")) # req-A: same worker, stale context!
pool.shutdown()
This is worse than a missing ID: request B's log lines, audit records or tenant checks inside the job silently use request A's values. The fix is the same per-job copy from step 3, which overrides whatever the worker thread inherited because ctx.run replaces the current context for the duration of the job. Alternatively, create pools at application startup, outside any request, so the inherited context is the empty startup context.
Verify: run the script with the flag and observe req-A twice; switch the submission to submit_with_context(pool, request_id.get) and it prints req-A then req-B.
Verification¶
Context crosses thread boundaries correctly when:
- The boundary map is known: every offloading call site is classified as copying (task,
to_thread, per-jobctx.run) or not, with none of the latter on request paths. - Thread logs carry request IDs: log lines written in worker threads during a request show that request's ID.
- No shared context objects: each executor job gets its own
copy_context(), and noRuntimeError: cannot enter contextappears under load. - Pools cannot serve stale context: pools are created at startup or every submission copies context, so behaviour is identical with
thread_inherit_contexton or off. - Values set in threads stay there: nothing set inside a job is visible to the request task or to the next job on the same worker.
Pitfalls & edge cases¶
- Library-managed thread pools. Database drivers, SDKs and gRPC run callbacks on their own threads without copying context. Wrap your callbacks with
ctx.runat registration time, or log with explicit IDs inside them. - Process pools. A
ProcessPoolExecutorjob runs in another interpreter; no context crosses. Pass the request ID and remaining deadline as ordinary arguments and set them in the worker if its code logs. - Copying too early. Capturing a context once at startup and reusing it for every job both shares one object across threads and freezes whatever values existed at capture time. Copy at submission.
call_soon_threadsafefrom worker threads. The callback runs with the context current in the calling thread; if that thread has no request context, neither does the callback. Capture the context in the request and passcontext=ctxexplicitly.- Assuming the flag's default. Code tested on a regular build and deployed on a free-threaded one changes behaviour for every raw
threading.Thread. Passcontext=explicitly on 3.14+ so behaviour does not depend on the build.
Frequently Asked Questions¶
Does asyncio.to_thread copy context variables?
Yes. asyncio.to_thread captures the current context with contextvars.copy_context and runs the function inside that copy on the default executor, so ContextVar values such as a request ID are visible in the thread. Changes made inside the thread stay in the copy and do not affect the calling task.
How do I propagate contextvars with loop.run_in_executor?
run_in_executor does not copy context by itself. Capture ctx = contextvars.copy_context() at the call site and submit functools.partial(ctx.run, fn, *args) to the executor. Create a new copy for every job, because a single Context object cannot be entered by two threads at the same time.
Do new threads inherit contextvars in Python 3.14?
It depends on the thread_inherit_context flag. When it is enabled, new threads start with a copy of the starting thread's context; it is off by default on regular builds and on by default on free-threaded builds. Python 3.14 also lets you pass context= to threading.Thread to choose the context explicitly regardless of the flag.
Why does my thread pool show another request's ID?
With thread context inheritance enabled, a pool worker thread copies the context of the request that caused it to be created and keeps it for all later jobs. Jobs submitted plainly then run with that stale context. Copy the context per job with ctx.run, or create the pool at startup outside any request.
Related¶
- Context Variables & Request Context — up to the topic overview for the copy-on-create model and failure modes.
- Propagating request IDs with contextvars — the middleware, logging and outbound plumbing this page keeps intact across threads.
- Asyncio Fundamentals & Event Loop Architecture — the section overview for tasks and the loop.