The high-level default for CPU parallelism: it spawns the workers, collects results as Futures, propagates exceptions, and cleans up โ and it's one word away from being threads.
concurrent.futures.ProcessPoolExecutor is the modern, high-level way to run work across cores. Raw Process makes you plumb queues, join by hand, and invent your own error handling. The executor does all of it: it keeps a fixed pool of worker processes alive, feeds them tasks, hands back a Future for each, and re-raises any worker exception when you read the result.
ProcessPoolExecutor first. Drop down to raw Process only for a few long-lived, hand-managed workers, and down to multiprocessing.Pool only when you specifically need imap/chunksize control.
The whole point of concurrent.futures is a single API over two very different execution models. Threads and processes share the exact same submit / map / Future surface โ you switch bottleneck strategies by changing one class name.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def work(x):
return x * x
if __name__ == "__main__":
# I/O-bound? threads. CPU-bound? processes. Same code below the swap:
Executor = ProcessPoolExecutor # <-- change this ONE word to ThreadPoolExecutor
with Executor() as pool:
print(list(pool.map(work, range(6)))) # [0, 1, 4, 9, 16, 25]
submit(fn, *args) schedules one call and immediately returns a Future โ a handle to a result that doesn't exist yet. Call .result() to block until it's ready (and to receive the value, copied back from the worker).
from concurrent.futures import ProcessPoolExecutor, as_completed
def slow_square(x):
total = 0
for i in range(x * 5_000_000): # variable CPU work per task
total += i
return x, x * x
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(slow_square, x) for x in range(6)]
# as_completed yields each Future the moment ITS worker finishes,
# not in submission order โ good for streaming results.
for fut in as_completed(futures):
arg, result = fut.result() # blocks only if this one isn't done yet
print(f"{arg} -> {result}")
Useful Future methods: .result(timeout=), .exception() (returns the raised exception instead of raising it), .done(), and .cancel() (only works while still queued, never once running).
Two ways to fan out; pick by whether you care about order or latency.
pool.map(fn, iterable) | submit + as_completed | |
|---|---|---|
| Result order | โ input order (a slow first task holds up the rest) | completion order (fastest first) |
| Ergonomics | โ
one line, feels like map() | more code, a Future per task |
| Per-task args | one arg (zip for more) | โ
arbitrary *args, **kwargs |
| Streaming / first-done-first | โ no | โ yes |
| Exceptions | raised while iterating results | raised at each .result() |
from concurrent.futures import ProcessPoolExecutor
def work(x):
return x * x
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
# map: simplest, results come back IN ORDER
ordered = list(pool.map(work, range(6))) # [0, 1, 4, 9, 16, 25]
# map with several arg columns via zip / positional lists:
def add(a, b): return a + b
# NOTE: map passes one item per iterable, column-wise:
sums = list(pool.map(add, [1, 2, 3], [10, 20, 30])) # [11, 22, 33]
print(ordered, sums)
map returns a lazy iterator โ nothing is actually collected until you consume it (e.g. list(...)). It also preserves input order, so one slow task at the front delays everything behind it. Use as_completed when you want to react to results as they land.
max_workers defaults to os.cpu_count(). For CPU-bound work, that's the right ceiling: each worker saturates one core, so with N cores you get ~Nร speedup and no more. Adding workers beyond core count doesn't add cores โ it just adds processes contending for the same cores, plus extra interpreter memory and context-switching overhead.
import os
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
print("cores:", os.cpu_count())
# Default is fine for CPU work; set it explicitly to leave a core for the OS/UI:
with ProcessPoolExecutor(max_workers=os.cpu_count() - 1) as pool:
...
This is the Python gotcha that bites everyone. A worker exception does not crash the pool and does not print. It is captured, pickled, and stashed in that task's Future. You never see it until you read the result โ at which point it re-raises in your process, with the original traceback attached as its cause.
from concurrent.futures import ProcessPoolExecutor, as_completed
def risky(x):
if x == 3:
raise ValueError(f"cannot process {x}") # blows up inside the worker
return x * 10
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
futures = {pool.submit(risky, x): x for x in range(6)}
for fut in as_completed(futures):
x = futures[fut]
try:
print(x, "->", fut.result()) # re-raises here for x == 3
except ValueError as e:
print(f"task {x} failed: {e}") # handle per-task, keep going
.result() (or iterate a map result), the exception is silently swallowed and your program looks like it "worked". Always consume results. A related trap: if the exception object itself isn't picklable, propagating it back can fail โ see Pattern 8.
A pool holds real OS processes. Leak it and you leave orphaned interpreters running. The with block calls shutdown(wait=True) on exit, which stops accepting new tasks and blocks until every in-flight task finishes and every worker is torn down.
from concurrent.futures import ProcessPoolExecutor
def work(x):
return x * x
if __name__ == "__main__":
with ProcessPoolExecutor() as pool: # spins up workers
results = list(pool.map(work, range(1000)))
# <-- shutdown(wait=True) ran here: all tasks done, all workers gone
print(sum(results))
# Equivalent without the sugar:
pool = ProcessPoolExecutor()
try:
results = list(pool.map(work, range(1000)))
finally:
pool.shutdown(wait=True) # never skip this
cancel_futures= (3.9+), and draining in-flight work are covered in Pattern 9.
| Aspect | submit() + as_completed | map() |
|---|---|---|
| Returns | a Future per call | a lazy result iterator |
| Result order | completion order | input order |
| Best for | streaming, mixed durations, per-task error handling | uniform tasks, simplest code |
| Exception surfaces | at fut.result() | while iterating results |
| Multiple arguments | โ native | via multiple iterables (column-wise) |
ProcessPoolExecutor covers ~90% of "make this CPU work parallel" needs. Use map for a clean fan-out, submit+as_completed when latency or per-task errors matter, always inside with, always guarded by if __name__ == "__main__". Reach lower only for imap/chunksize (Pattern 5).