Skip to content

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

What each thread boundary sees A grid of 5 rows by 2 columns. What each thread boundary sees call value read copies context? asyncio.create_task req-1 yes asyncio.to_thread req-1 yes loop.run_in_executor - no executor.submit - no threading.Thread - no (flag off) Same threads, different calls: the copy happens in the call, not the pool.

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.

A per-job context copy 4 stages from request task to job ends. A per-job context copy request task copy_context() executor queue job waits worker thread ctx.run(fn) job ends copy discarded The copy is taken at submission, so queue time cannot change what the job sees.

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.

A pooled worker keeps its first context 4 lanes over time. A pooled worker keeps its first context request A submit: creates worker request B submit worker, plain job A sees A job B sees A worker, ctx.run job A sees A job B sees B time → Inheritance happens once per thread; ctx.run happens once per job.

Verification

Context crosses thread boundaries correctly when:

  • The boundary map is known: every offloading call site is classified as copying (task, to_thread, per-job ctx.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 no RuntimeError: cannot enter context appears under load.
  • Pools cannot serve stale context: pools are created at startup or every submission copies context, so behaviour is identical with thread_inherit_context on 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.run at registration time, or log with explicit IDs inside them.
  • Process pools. A ProcessPoolExecutor job 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_threadsafe from 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 pass context=ctx explicitly.
  • 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. Pass context= 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.