Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

Python ContextVar disappeared in an executor? Prove the boundary and backflow contract

Distinguish Task, to_thread, thread-pool and process boundaries; copy once per thread submission and return mutations explicitly.

Illustration comparing ContextVar behavior across asyncio Task, to_thread and executors
Illustration: first identify the boundary; propagation policy belongs to the caller API. Watch at Ease

ContextVar values do not cross every concurrency boundary the same way. asyncio Tasks preserve task-local context and asyncio.to_thread copies the current Context into its worker, while a plain ThreadPoolExecutor submission or loop.run_in_executor path needs an explicit copy when the surrounding helper does not provide one.

Treat propagation into a worker and changes coming back as separate contracts. Capture a fresh copy at each thread submission, run one call inside it, return any changed value explicitly, reject process-pool assumptions and prove isolation with two concurrent request IDs.

1. Name the boundary before adding a wrapper

Record whether the call stays in an asyncio Task, uses asyncio.to_thread, calls loop.run_in_executor, submits directly to ThreadPoolExecutor or crosses into a process. Do not infer behavior from the function name or a framework label.

Log one non-secret request marker on both sides of the boundary. A missing value proves propagation failed; the previous request value proves a worker-leak bug; a value visible only after an explicit copy proves the wrapper owns propagation.

2. Copy at submission time for thread executors

For a plain thread submission, capture ctx=contextvars.copy_context() in the submitting request and submit ctx.run with the target callable and arguments. Capture once per submission, not once when constructing a long-lived pool.

Do not run the same Context object concurrently in two workers: Python raises RuntimeError when an already entered Context is entered again. A fresh copy per call also binds the intended request snapshot instead of a startup snapshot.

3. Separate inward propagation from backflow

asyncio.to_thread documents inward propagation. A framework helper may implement the same pattern around run_in_executor. Neither rule turns a worker ContextVar assignment into a write-back channel to the awaiting Task.

If the worker computes a new tenant, span or authorization result, return it as ordinary data and let the caller decide whether to set or reset its own ContextVar. Use the token from set() in try/finally when a temporary caller-side override is intended.

4. Keep thread and process contracts different

The CPython issue rejected one automatic run_in_executor rule partly because that API could use a ProcessPoolExecutor while Context objects are not picklable. Do not hide a process boundary behind a thread-only wrapper.

For a process, pass a small explicit serializable request context, validate it in the worker and return results explicitly. Treat credentials and large mutable objects according to the process API security and ownership model, not as ambient ContextVar state.

5. Prove isolation with the real caller

Launch two overlapping calls with distinct markers A and B through the exact production helper. Gate them so both workers are active, assert A sees only A and B sees only B, and assert an unwrapped control sees the documented default.

Inside each worker set a different local value and return it. After await or Future.result(), assert the caller still holds its original marker until it deliberately changes or resets it. Repeat for shutdown and error paths so pooled threads never retain a previous request value.

Illustration showing copy_context capture, ctx.run and two-request isolation checks
Illustration: capture a fresh Context for one thread call and return changes explicitly. Watch at Ease