InterpreterPoolExecutor

Updated

October 31, 2026

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.stdout redirection, monkeypatches, and module-level caches in the main interpreter do not appear in workers unless you put them in the initializer.
  • 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 an ExecutionFailed summary. Failed worker init raises BrokenInterpreterPool.
  • map(..., chunksize=…) has no effect on thread- and interpreter-based pools (same as ThreadPoolExecutor). buffersize (3.14+) still limits how far ahead map submits.

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.py

Output:

[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.py

Output (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.py

Output:

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.py

Output:

['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.py

Output (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

  1. In ipe_map.py, set max_workers=1 and time a larger range; then bump workers and compare wall time on a multi-core machine.
  2. In ipe_submit.py, raise RuntimeError("burnt") inside square for n == 5 and catch it from fut.result().
  3. In ipe_init.py, pass a non-pickleable initargs object (for example a threading.Lock) and observe the failure mode.
  4. Swap InterpreterPoolExecutor for ProcessPoolExecutor in ipe_map.py and confirm the results match — then note startup cost differences on your machine.