Parallel Python the Easy Way: A Practical Deep Dive into concurrent.futures

Learn how to run Python code in parallel without wrestling with raw threads or processes. This deep dive into concurrent.futures covers thread vs. process pools, futures, as_completed, timeouts, error handling, and the pitfalls that trip people up.

Parallel Python the Easy Way: A Practical Deep Dive into concurrent.futures

Sooner or later every Python program hits a wall where doing one thing at a time is simply too slow. Maybe you are downloading a few hundred URLs, resizing a folder full of images, or crunching numbers over a big list. The good news is that Python's standard library gives you a clean, high-level way to run that work in parallel without ever touching a raw Thread or Process object: the concurrent.futures module.

This post is a practical tour of concurrent.futures — what a "future" actually is, when to reach for threads versus processes, how to collect results as they finish, and the handful of pitfalls that cause most of the confusion. Everything here works with the standard library, no third-party packages required.

The core idea: submit work, get a future back

The module gives you an executor — a managed pool of workers — and you hand it callables to run. Each time you submit a callable you get back a Future: a lightweight object that represents a result that may not exist yet. You can ask a future whether it is done, wait for its result, or check whether it raised an exception.

from concurrent.futures import ThreadPoolExecutor

def square(x):
    return x * x

with ThreadPoolExecutor(max_workers=4) as executor:
    future = executor.submit(square, 10)
    print(future.done())      # probably False right away
    print(future.result())    # blocks until the value is ready -> 100

The with block matters: leaving it calls executor.shutdown(), which waits for all pending work to finish and releases the worker threads. You almost never want to manage that lifecycle by hand.

Threads or processes? Pick by workload

concurrent.futures ships two interchangeable executors, and choosing the right one is the single most important decision you will make.

ThreadPoolExecutor runs your callables in threads inside the same process. Because of the Global Interpreter Lock (GIL), only one thread executes Python bytecode at a time — so threads do not speed up pure-Python number crunching. What they are excellent at is I/O-bound work: while one thread waits on a network response or a disk read, the GIL is released and another thread runs. Downloading files, calling APIs, and querying databases are the classic cases.

ProcessPoolExecutor runs your callables in separate processes, each with its own interpreter and its own GIL. That means it can genuinely use multiple CPU cores in parallel, which is what you want for CPU-bound work like image processing, parsing, or heavy math. The trade-off is overhead: arguments and return values are pickled and shipped between processes, so it only pays off when each task does a meaningful chunk of work.

import math
from concurrent.futures import ProcessPoolExecutor

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, math.isqrt(n) + 1):
        if n % i == 0:
            return False
    return True

numbers = [112272535095293, 112582705942171, 115280095190773, 97, 15]

with ProcessPoolExecutor() as executor:
    results = list(executor.map(is_prime, numbers))

print(results)  # [True, True, True, True, False]

A quick rule of thumb: waiting on something external → threads; computing something in Python → processes.

map() for simple fan-out

executor.map() mirrors the built-in map(), running the function over an iterable and yielding results in the original input order. It is the most concise way to parallelize a loop:

import urllib.request
from concurrent.futures import ThreadPoolExecutor

urls = [
    "https://example.com",
    "https://www.python.org",
    "https://docs.python.org/3/",
]

def fetch_size(url):
    with urllib.request.urlopen(url, timeout=10) as resp:
        return url, len(resp.read())

with ThreadPoolExecutor(max_workers=8) as executor:
    for url, size in executor.map(fetch_size, urls):
        print(f"{size:>8} bytes  {url}")

Two things to remember about map(). First, results come back in input order, not completion order — so a single slow item holds up everything behind it when you iterate. Second, if a task raises, the exception is re-raised when you reach that item while iterating, not at submission time.

submit() and as_completed() for results as they land

When you want to react to each result the moment it is ready — for a progress bar, or to start writing output early — pair submit() with as_completed(). It yields futures in the order they finish, regardless of submission order.

import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def slow_double(x):
    time.sleep(x * 0.1)
    return x * 2

with ThreadPoolExecutor(max_workers=4) as executor:
    # Map each future back to its input so we know what finished.
    future_to_x = {executor.submit(slow_double, x): x for x in [5, 1, 3, 2]}

    for future in as_completed(future_to_x):
        x = future_to_x[future]
        print(f"input {x} -> {future.result()}")

That dictionary trick — mapping each future to the data that produced it — is the idiomatic way to remember context, because as_completed() hands you futures with no memory of their inputs.

Handling errors without losing the whole batch

A future stores whatever its callable produced — including an exception. Calling .result() re-raises that exception in your main thread, so you can wrap each call and keep processing the rest of the batch:

from concurrent.futures import ThreadPoolExecutor, as_completed

def risky(x):
    if x == 3:
        raise ValueError(f"cannot handle {x}")
    return x * x

with ThreadPoolExecutor() as executor:
    futures = [executor.submit(risky, i) for i in range(5)]
    for future in as_completed(futures):
        try:
            print("ok:", future.result())
        except ValueError as exc:
            print("failed:", exc)

If you prefer to inspect rather than raise, future.exception() returns the exception object (or None) without throwing. Either way, the key insight is that an error in one task never silently disappears and never crashes the pool — it waits patiently inside its future until you ask for the result.

Timeouts and partial waits

Both result() and as_completed() accept a timeout in seconds, raising TimeoutError if the work is not done in time. This is essential when a task might hang and you refuse to block forever:

from concurrent.futures import ThreadPoolExecutor, TimeoutError

with ThreadPoolExecutor() as executor:
    future = executor.submit(time.sleep, 5)
    try:
        future.result(timeout=1)
    except TimeoutError:
        print("still running after 1 second")

For finer control there is concurrent.futures.wait(), which blocks until some condition is met and returns two sets, (done, not_done). Pass return_when=FIRST_COMPLETED to wake up as soon as anything finishes, or FIRST_EXCEPTION to stop early on the first failure.

from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED

with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [executor.submit(time.sleep, d) for d in (0.05, 0.2, 0.3)]
    done, not_done = wait(futures, return_when=FIRST_COMPLETED)
    print(f"{len(done)} done, {len(not_done)} still running")

Common pitfalls

Using threads for CPU-bound work. This is the number-one mistake. If your function is pure Python math, a thread pool will not make it faster — the GIL serializes it. Reach for ProcessPoolExecutor instead.

Forgetting that ProcessPoolExecutor pickles everything. Functions and arguments must be picklable, which means the target function has to be defined at module top level, not nested inside another function or a lambda. Large arguments are copied to each worker, so passing a giant DataFrame to thousands of tiny tasks can cost more than it saves.

Swallowing exceptions by never calling result(). If you submit work and never retrieve the result, a raised exception sits silently inside the future and you will think the task succeeded. Always consume your futures.

Setting max_workers blindly. For I/O-bound thread pools you can often go well above the core count since workers spend most of their time waiting. For process pools, the default (the number of CPUs) is usually the sweet spot; more processes than cores just adds context-switching overhead.

Oversized batches with map(). On a process pool, tune the chunksize argument for large inputs. Sending items one at a time incurs per-item IPC overhead; grouping them into chunks amortizes it and can dramatically improve throughput.

Wrap-up and next steps

concurrent.futures hits a sweet spot: it is high-level enough that you never manage threads or processes by hand, yet flexible enough to cover most real parallelism needs. Start with ThreadPoolExecutor and map() for I/O-bound fan-out, switch to ProcessPoolExecutor when the CPU is the bottleneck, and reach for submit() plus as_completed() when you need results the moment they arrive.

From here, two directions are worth exploring. If your workload is heavily I/O-bound and involves thousands of concurrent operations, look at asyncio as a more scalable alternative to thread pools. And if you are on Python 3.13 or newer, keep an eye on the experimental free-threaded (no-GIL) builds, which change the calculus for when threads can accelerate CPU-bound code. Until then, remember the rule that will steer you right almost every time: threads for waiting, processes for computing.