DEV Community

Taylor Wang
Taylor Wang

Posted on

Field Notes From a 48-Hour Fight With ThreadPoolExecutor and contextvars

Have you ever parallelized a sequential Python loop, watched the wall clock drop, and then spent two days chasing blank request IDs? I ran that experiment on purpose this week, as a 48-hour field notebook against contextvars and thread pools. The sequential helpers already worked, and every log line in that path printed a real request identifier from the ContextVar. The moment I wrapped those helpers in ThreadPoolExecutor, every worker thread logged request_id=None and then raised a RuntimeError.

Why did a supposedly local context variable vanish the instant work crossed a thread boundary in this lab? That question sat on my desk for two nights, and the stdlib answer is smaller than the debugging trail I actually walked. I am writing the notes in order, including the false leads, because those false leads are what ate the first night.

Hour 0: the sequential code that actually behaved

I started with a tiny request-scoped helper, the kind most services grow without thinking about thread boundaries. A ContextVar held a request ID so downstream logging and HTTP headers could read it without extra function arguments everywhere. Does that design feel a little magical when you first adopt it for a quiet service? It felt boring to me, and boring is usually a compliment in this kind of plumbing.

# context_trace.py
from __future__ import annotations

from contextvars import ContextVar, Token
import logging

request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
log = logging.getLogger("trace")


def bind_request(rid: str) -> Token:
    return request_id.set(rid)


def current_request_id() -> str | None:
    return request_id.get()


def fetch_one(url: str) -> dict:
    rid = current_request_id()
    log.info("fetch start url=%s request_id=%s", url, rid)
    if rid is None:
        raise RuntimeError(f"missing request_id for {url}")
    return {"url": url, "request_id": rid}
Enter fullscreen mode Exit fullscreen mode

That version is deliberately small, because the later failure only makes sense if the baseline stays green in one thread. Call bind_request, then fetch_one, and the identifier shows up in the log line and the returned payload. Would you even write a test for this on a quiet afternoon when the happy path is obvious? I did, because a later parallel helper kept the sequential test green while still being completely wrong.

# test_sequential.py
from context_trace import bind_request, fetch_one, request_id


def test_sequential_fetch_sees_bound_id() -> None:
    token = bind_request("req-seq-1")
    try:
        payload = fetch_one("https://example.invalid/a")
        assert payload["request_id"] == "req-seq-1"
    finally:
        request_id.reset(token)
Enter fullscreen mode Exit fullscreen mode

Run that file alone before you touch a pool, because this is the only green bar that still means what you think it means:

python -m pytest test_sequential.py -v --tb=short
Enter fullscreen mode Exit fullscreen mode

Hour 2: I asked a free assistant to wrap the loop

I wanted ten independent fetches to overlap instead of marching through a single thread like a queue. The change looked like a textbook executor example, so I drafted it with MonkeyCode's free model access on the free server option rather than retyping boilerplate from memory. I treated that draft as a starting point, not as a reviewed patch, which is the only reason this notebook stays honest.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The assistant returned a wrapper that most of us would merge without blinking during a busy review. I merged it, because the sequential test still passed and the new helper looked like every snippet on the internet. Have you noticed how easy it is to trust a green test that never enters the new code path you just added?

# parallel_fetch.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from context_trace import fetch_one


def fetch_many(urls: list[str], workers: int = 8) -> list[dict]:
    out: list[dict] = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futs = {pool.submit(fetch_one, url): url for url in urls}
        for fut in as_completed(futs):
            out.append(fut.result())
    return out
Enter fullscreen mode Exit fullscreen mode

Do you see the bug yet, sitting in the submit line with no copy_context at all? I did not see it at hour two, because the type checker was quiet and the sequential test was still green. The parallel test had not been written, which is the actual plot twist of this notebook.

What I tried during the first night

I treated this like a logging configuration problem, because the visible symptom was a missing identifier in worker log lines. That was the wrong layer, and it cost me a stretch of hours I will not get back. Here is the actual trail, in order, including the controls that should have ended the race theory.

  1. I raised the logging level to DEBUG and added a logging.Filter that injected request_id into every record.
  2. I printed threading.get_ident() next to every log line, hoping one particular worker thread was uniquely broken.
  3. I swapped as_completed for executor.map, because maybe completion order was scrambling some hidden side channel.
  4. I pinned max_workers=1, which still failed, which should have ended the race theory immediately that night.
  5. I grepped for request_id.set and confirmed the main thread still bound the value before each submit call.

Commands from that false-lead stretch, saved here so I do not pretend I was efficient:

python -c "import logging, threading; logging.basicConfig(level=logging.DEBUG); print(threading.get_ident())"
grep -n "request_id.set\|ThreadPoolExecutor\|as_completed" *.py
python -m pytest test_sequential.py -v
Enter fullscreen mode Exit fullscreen mode

With one worker, there is no race between workers sharing a mutable cache or a global dict. So why was the bound value still missing inside that single worker thread after submit returned? That is the question I should have asked at hour four, not hour fourteen, and the delay is the real field note.

What actually broke

ContextVar is not thread-local storage in the threading.local sense, and it is not a process-wide global either. It is a lookup in the current contextvars.Context object, which asyncio tasks copy and which ThreadPoolExecutor workers do not copy. Can you hold those two runtime stories in your head at the same time without mixing them during review? I could not, at least not until the single-worker experiment forced the issue.

The main thread can bind request_id to req-1 and then call pool.submit(fetch_one, url) with a perfectly valid callable. The worker starts with a fresh context, so request_id.get() returns the default, which in my helper was None. The fetch then raises a RuntimeError, or worse, it logs an empty identifier and continues talking to an upstream with no trace.

Would max_workers=1 copy that context into the worker as if the code were still sequential and local? A thread pool of size one is still a different thread, and submit does not wrap your callable in copy_context().run. That is why the single-worker experiment was the real turning point, not the logging filters I had been polishing.

I also checked asyncio.to_thread, because I almost migrated the loop to async just to fix logging. That helper is a different contract, and mixing it with raw executors is how this bug survives. asyncio.to_thread copies the current context into the worker thread on purpose, starting in Python 3.9, while ThreadPoolExecutor.submit does not copy anything.

Hour 24: a reproduction you can run without a service

I stopped poking the real fetcher and wrote a file that fails in one process, with no network and no framework. If a bug needs the internet to show itself, it is not finished being a unit test yet. This is the artifact I wish I had committed before asking anyone, human or model, to parallelize the loop.

# test_context_pool.py
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar, copy_context

request_id = ContextVar("request_id", default=None)


def read_id(_n: int) -> object:
    return request_id.get()


def test_thread_pool_does_not_see_main_context() -> None:
    request_id.set("req-lab-1")
    with ThreadPoolExecutor(max_workers=2) as pool:
        values = list(pool.map(read_id, range(4)))
    assert values == [None, None, None, None]


def test_copy_context_run_propagates_the_id() -> None:
    request_id.set("req-lab-1")
    ctx = copy_context()

    with ThreadPoolExecutor(max_workers=2) as pool:
        futs = [pool.submit(ctx.run, read_id, n) for n in range(4)]
        values = [fut.result() for fut in futs]
    assert values == ["req-lab-1"] * 4
Enter fullscreen mode Exit fullscreen mode

Run it like this from the same directory, without any extra fixtures or compose files sitting nearby:

python -m pytest test_context_pool.py -v --tb=short
Enter fullscreen mode Exit fullscreen mode

The first test is the field note I wish I had at hour two, because it names the boundary without a web server. The second test is the smallest repair that still uses a thread pool and still asserts the identifier. Notice that copy_context() snapshots the context at submit time, so later set calls on the main thread do not leak into in-flight workers.

Is that snapshot behavior what you want for a request identifier that should stay stable for the whole fetch? Yes, and it is also what you want for a locale or a feature flag that must not flip mid-flight. If you need live updates into running workers, you are designing a message channel, not a context variable.

Hour 36: the wrapper I would actually keep

Propagating context at every submit site is easy to forget when a new call site appears during a refactor. I wrapped the executor so the call sites stay boring, and so a reviewer can see the policy in one class. Is subclassing ThreadPoolExecutor a little cute for a lab notebook? Yes, but one policy object still beats twelve copy_context calls.

# context_pool.py
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from typing import Callable, TypeVar

T = TypeVar("T")


class ContextPreservingExecutor(ThreadPoolExecutor):
    """Submit callables inside a snapshot of the caller's contextvars.

    Lab helper, not a runtime. It copies context at submit() time.
    Mutations inside the worker stay in the worker.
    """

    def submit(self, fn: Callable[..., T], /, *args, **kwargs):
        ctx = copy_context()
        return super().submit(ctx.run, fn, *args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

Then the parallel helper becomes almost the original sketch, which is the point of putting the snapshot in one place.

# parallel_fetch_v2.py
from context_pool import ContextPreservingExecutor
from context_trace import fetch_one


def fetch_many(urls: list[str], workers: int = 8) -> list[dict]:
    with ContextPreservingExecutor(max_workers=workers) as pool:
        futs = [pool.submit(fetch_one, url) for url in urls]
        return [fut.result() for fut in futs]
Enter fullscreen mode Exit fullscreen mode

If you only have one submit site in the whole codebase, skip the subclass and write the snapshot inline beside that call. The decision table from the notebook now lives next to the tests, because I kept mixing these APIs in my head.

API Copies contextvars to the worker? Use when
Sequential call Yes, same context Default path, easiest to test
ThreadPoolExecutor.submit No Work with explicit arguments
copy_context().run inside submit Yes, snapshot at submit Request IDs, locale, flags
asyncio.to_thread (3.9+) Yes, by design Already inside an event loop
ProcessPoolExecutor No, different process Picklable CPU-bound work

What I would repeat

I would write the failing pool test before asking any assistant to just parallelize this loop for wall-clock reasons. The sequential green test is a trap, because it never enters a worker thread and never observes the default. I would also print request_id.get() on both sides of submit once, with thread identifiers, before touching logging configuration again.

I would keep passing the request identifier as an explicit argument for anything that leaves the process or crosses a queue. Context is a convenience inside one task tree, not a wire format you can shove through pickle. And I would treat max_workers=1 as a scientific control, not as a production setting that should behave like sequential code.

Commands I now keep next to the test file, because they are cheap and they force the boundary into view:

python -m pytest test_sequential.py test_context_pool.py -v --tb=short
python -c "from contextvars import copy_context; print(list(copy_context().keys()))"
python -c "import threading; print(threading.current_thread().name)"
Enter fullscreen mode Exit fullscreen mode

Limitations, and who should not copy this

This approach does not make thread pools magically safe for shared mutable state, and it does not defeat the GIL for CPU-bound work. A copied context is a snapshot, so a worker that calls request_id.set() will not update the main thread behind your back. If you need two-way conversation between workers and the parent, pass a queue.Queue or return values instead of hoping context flows backward.

Do not use ContextPreservingExecutor as an excuse to hide authentication secrets in context variables and then spray them across a pool. Explicit arguments are still easier to audit in review, and they survive a move to processes. People already on asyncio.to_thread do not need this subclass, because that helper already copies context by design.

People using ProcessPoolExecutor cannot use this pattern at all, because contextvars do not travel through pickle into another interpreter. I also would not roll this wrapper out as a framework if your team has not yet written the two tests above. Without the failing assertion, the wrapper is folklore, and folklore is how I burned the first night.

The 48-hour lesson was not that thread pools are cursed, or that ContextVar was a mistake for request identifiers. The lesson was that a green sequential test plus a fluent executor snippet can hide a context boundary until production-shaped traffic shows it. If you want a throwaway environment to rerun these files, MonkeyCode's free server option is enough for pytest. The assertion in the failing test is still the part that actually matters for this notebook.

Top comments (0)