InterpreterPoolExecutor
InterpreterPoolExecutor
Python 3.14 adds concurrent.futures.InterpreterPoolExecutor: a ThreadPoolExecutor subclass where each worker thread owns its own interpreter (and its own GIL). You get true multi-core CPU parallelism inside one process, with a futures API you already know. The boring default is still ThreadPoolExecutor for shared-memory I/O and ProcessPoolExecutor for hard isolation. Reach for the interpreter pool when the work is CPU-bound, the callable and arguments are pickleable, and you want process-like isolation without paying for a process per worker.
This chapter is the pool-focused companion to subinterpreters. It does not replace threads or processes.
Mental model
| Executor | Workers | GIL | Isolation | Call/args contract |
|---|---|---|---|---|
ThreadPoolExecutor |
Threads, one interpreter | Shared | Shared heap (races real) | Any callable; shared objects OK |
InterpreterPoolExecutor |
Threads, one interpreter each | Per worker | Separate runtimes; no shared mutables | Pickle callable + args + return |
ProcessPoolExecutor |
OS processes | Per process | Hard (separate address spaces) | Pickle; __main__ importable |
- Each worker imports modules fresh.
sys.stdoutredirection, monkeypatches, and module-level caches in the main interpreter do not appear in workers unless you put them in theinitializer. - The executor pickles the callable, arguments,
initializer/initargs, and return values (same idea as a process pool). Closures, lambdas, and locks fail. Prefer module-level functions. - Isolation is not a security boundary. Same OS process, same privileges. A bad extension module can still corrupt the process.
- Exceptions from workers try to preserve the original;
__cause__may be anExecutionFailedsummary. Failed worker init raisesBrokenInterpreterPool. map(..., chunksize=…)has no effect on thread- and interpreter-based pools (same asThreadPoolExecutor).buffersize(3.14+) still limits how far aheadmapsubmits.
Worked examples
Case 1: Map CPU chunks across interpreters
Save as ipe_map.py. Same shape as a process pool: map a pure function over ints. Workers are interpreters in threads.
# ipe_map.py
from concurrent.futures import InterpreterPoolExecutor
def dig(n):
total = 0
for i in range(n):
total += i * i
return total
def main():
with InterpreterPoolExecutor(max_workers=2) as pool:
print(list(pool.map(dig, [200, 300, 400])))
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ipe_map.pyOutput:
[2646700, 8955050, 21253400]
Keep dig stateless and top-level. That is the whole happy path.
Case 2: submit + as_completed
Save as ipe_submit.py. Use futures when results should arrive out of order or you need per-task error handling.
# ipe_submit.py
from concurrent.futures import InterpreterPoolExecutor, as_completed
def square(n):
return n * n
def main():
with InterpreterPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(square, n): n for n in (3, 5, 8)}
for fut in as_completed(futures):
n = futures[fut]
print(n, "->", fut.result())
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ipe_submit.pyOutput (order may vary):
3 -> 9
5 -> 25
8 -> 64
fut.result() re-raises the worker exception. Catch around result(), not around submit().
Case 3: When vs Thread vs Process
Save as ipe_choose.py. Print a tiny decision table you can keep next to a service’s concurrency knobs.
# ipe_choose.py
def pick(workload, needs_hard_isolation, pickle_ok):
if needs_hard_isolation:
return "ProcessPoolExecutor"
if workload == "io":
return "ThreadPoolExecutor"
if workload == "cpu" and pickle_ok:
return "InterpreterPoolExecutor"
if workload == "cpu" and not pickle_ok:
return "ProcessPoolExecutor or redesign for pickle/shareable"
return "ThreadPoolExecutor"
def main():
cases = [
("io", False, True),
("cpu", False, True),
("cpu", True, True),
("cpu", False, False),
]
for workload, hard, pickle_ok in cases:
print(f"{workload=} {hard=} {pickle_ok=} -> {pick(workload, hard, pickle_ok)}")
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ipe_choose.pyOutput:
workload='io' hard=False pickle_ok=True -> ThreadPoolExecutor
workload='cpu' hard=False pickle_ok=True -> InterpreterPoolExecutor
workload='cpu' hard=True pickle_ok=True -> ProcessPoolExecutor
workload='cpu' hard=False pickle_ok=False -> ProcessPoolExecutor or redesign for pickle/shareable
If a C extension is not multi-interpreter-safe, fall back to processes even when pickle would work.
Case 4: Initializer runs in each worker interpreter
Save as ipe_init.py. Use initializer to import heavy modules once per worker. Remember: it is pickled into each interpreter — keep initargs small and pickleable.
# ipe_init.py
from concurrent.futures import InterpreterPoolExecutor
_worker_label = None
def init_worker(label):
global _worker_label
_worker_label = label
def who(n):
return f"{_worker_label}:{n * n}"
def main():
with InterpreterPoolExecutor(
max_workers=2,
initializer=init_worker,
initargs=("desk",),
) as pool:
print(sorted(pool.map(who, [2, 3, 4])))
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ipe_init.pyOutput:
['desk:4', 'desk:9', 'desk:16']
initializer failure breaks the pool (BrokenInterpreterPool). Keep it short and reliable.
The trap
Save as ipe_pickle_fail.py. A lambda or a nested function is not a reliable pickle target. Use a module-level function instead.
# ipe_pickle_fail.py
from concurrent.futures import InterpreterPoolExecutor
def double(n):
return n * 2
def main():
with InterpreterPoolExecutor(max_workers=1) as pool:
try:
print(pool.submit(lambda n: n * 2, 7).result())
except Exception as exc:
print(type(exc).__name__)
print(pool.submit(double, 7).result())
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ipe_pickle_fail.pyOutput (exception type may be PicklingError or a wrapper such as ExecutionFailed depending on the failure path):
PicklingError
14
Second trap: mutating a “shared” list from main and expecting workers to see it. They will not — each interpreter has its own objects. Pass immutable inputs in; return new values out.
Third trap: using the pool as a sandbox. Same uid, same filesystem, same process. Untrusted code still needs a real isolation boundary (container, process with dropped privileges, or a dedicated service).
The boring rule
- I/O + shared memory →
ThreadPoolExecutor. CPU + pickleable →InterpreterPoolExecutor. Hard isolation / unsafe extensions →ProcessPoolExecutor. - Callables and arguments must pickle. Prefer module-level functions; no lambdas, no closures over cells, no locks as arguments.
- Always use the executor as a context manager (or call
shutdown) so worker interpreters are cleaned up. - Do not treat interpreter isolation as a security sandbox.
- Prefer returning plain data (ints, strings, tuples, dicts of those) rather than trying to share mutable objects across workers.
Try this
- In
ipe_map.py, setmax_workers=1and time a larger range; then bump workers and compare wall time on a multi-core machine. - In
ipe_submit.py, raiseRuntimeError("burnt")insidesquareforn == 5and catch it fromfut.result(). - In
ipe_init.py, pass a non-pickleableinitargsobject (for example athreading.Lock) and observe the failure mode. - Swap
InterpreterPoolExecutorforProcessPoolExecutorinipe_map.pyand confirm the results match — then note startup cost differences on your machine.