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

Learn to parallelize Python with concurrent.futures: when to reach for threads vs. processes, how submit(), map(), and as_completed() work, handling errors and timeouts, and the pitfalls that bite people in production.

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

Most Python code runs one thing at a time, and for a long while that's fine. Then you hit a wall: you need to download 200 URLs, resize 5,000 images, or crunch a pile of numbers, and doing it sequentially takes forever. You reach for the threading or multiprocessing modules, wire up queues and worker loops, and suddenly half your code is plumbing.

The concurrent.futures module, in the standard library since Python 3.2, exists to delete that plumbing. It gives you one clean, high-level API — the executor — that hands work to a pool of threads or processes and gives you back futures: placeholders for results that haven't arrived yet. You write almost identical code whether you're I/O-bound or CPU-bound; you just swap one class name. This post walks through the whole module: the two executors, the three ways to collect results, error and timeout handling, and the pitfalls that trip people up.

The mental model: executors and futures

There are two pieces. An executor is a managed pool of workers you submit callables to. A future is an object representing one pending result — you can ask it whether it's done, wait for its value, or read the exception it raised. You almost never create futures yourself; the executor hands them to you.

The simplest possible use is map(), which mirrors the built-in map() but runs the calls concurrently and returns results in input order:

from concurrent.futures import ThreadPoolExecutor

def square(n):
    return n * n

with ThreadPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(square, range(10)))

print(results)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Using the executor as a context manager is the idiomatic pattern: leaving the with block calls shutdown(), which waits for all pending work to finish before your program moves on. No manual join loops, no dangling threads.

Threads vs. processes: the one decision that matters

The module ships two executors with an identical interface: ThreadPoolExecutor and ProcessPoolExecutor. Choosing between them comes down to what your work is actually waiting on.

Use threads for I/O-bound work — network requests, disk reads, database calls. These tasks spend most of their time waiting, and while one thread waits, Python's Global Interpreter Lock (GIL) lets another run. Threads are cheap to start and share memory, so passing data around is free.

Use processes for CPU-bound work — number crunching, parsing, compression — where the code is genuinely burning CPU cycles. Because each process has its own interpreter and its own GIL, ProcessPoolExecutor gives you true parallelism across cores. Here's a CPU-heavy example, testing large numbers for primality:

from concurrent.futures import ProcessPoolExecutor
import math

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

NUMBERS = [112272535095293, 112582705942171, 115280095190773, 15, 100]

if __name__ == "__main__":
    with ProcessPoolExecutor() as ex:
        for n, prime in zip(NUMBERS, ex.map(is_prime, NUMBERS)):
            print(n, prime)

Two things to notice. First, ProcessPoolExecutor defaults max_workers to the number of CPUs on your machine, which is usually what you want. Second — and this bites everyone once — process pools require that guard: the if __name__ == "__main__": block is not optional on Windows and macOS, where new processes are spawned by re-importing your module. Without it you'll get an infinite spawn loop or a crash.

submit() and as_completed(): results as they arrive

map() is elegant but has two limits: results come back in input order (so one slow item blocks everything behind it), and every call takes the same single-argument shape. When you need more control, use submit(), which schedules one callable and immediately returns a Future.

Pair submit() with as_completed(), which yields futures the moment each one finishes, regardless of submission order. This is the pattern you want for a dashboard, a progress bar, or anything where you'd rather handle the fast results first:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def work(n):
    time.sleep(0.1 * n)
    if n == 3:
        raise ValueError("boom on 3")
    return n * 10

with ThreadPoolExecutor(max_workers=5) as ex:
    futures = {ex.submit(work, n): n for n in range(6)}
    for fut in as_completed(futures):
        n = futures[fut]
        try:
            print(f"n={n} -> {fut.result()}")
        except Exception as e:
            print(f"n={n} failed: {e!r}")

The {future: metadata} dictionary is a common idiom: since as_completed() hands you futures out of order, you need a way to remember which input each one came from. Here we map each future back to its n.

Errors don't disappear — they wait in the future

This is the single most important thing to understand about the module. When a worker raises an exception, it does not crash your program. The exception is captured and stored inside the future, and it re-raises the moment you call future.result(). If you never call result(), you'll never see the error — a silent failure that can hide real bugs for weeks.

That's why the loop above wraps fut.result() in try/except. The same rule applies to map(): exceptions surface when you iterate the results, not when you call map(). A bare ex.map(...) whose result you never consume will swallow every error inside it.

Timeouts: don't wait forever

Both result() and as_completed() accept a timeout in seconds. If the value isn't ready in time, a TimeoutError is raised so you can move on instead of blocking indefinitely:

from concurrent.futures import ThreadPoolExecutor, TimeoutError
import time

def slow():
    time.sleep(2)
    return "done"

with ThreadPoolExecutor() as ex:
    fut = ex.submit(slow)
    try:
        print(fut.result(timeout=0.2))
    except TimeoutError:
        print("timed out waiting for result")

Import that TimeoutError straight from concurrent.futures. On Python 3.10 and earlier it is a distinct class from the built-in TimeoutError, so catching the built-in silently misses it. (In 3.11+ they were unified into the same exception, but importing from the module keeps your code correct on every version.)

A hard truth worth stating: a timeout lets you stop waiting, but it does not kill the underlying task. The worker keeps running. Future.cancel() only succeeds if the task hasn't started yet — once it's executing, it runs to completion. There is no safe, universal way to forcibly abort a running thread in Python, so design tasks to finish on their own.

Shared state and the classic race condition

Because threads share memory, two of them touching the same variable at once is a race condition, even for something that looks atomic like counter += 1 (it's really a read, an add, and a write). Guard shared mutable state with a lock:

from concurrent.futures import ThreadPoolExecutor
import threading

counter = 0
lock = threading.Lock()

def add_many():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

with ThreadPoolExecutor(max_workers=8) as ex:
    list(ex.map(lambda _: add_many(), range(8)))

print(counter)  # 800000, reliably

Process pools sidestep this — each process has its own memory, so there's nothing to share by accident — but that isolation is also why passing large objects to a ProcessPoolExecutor is expensive: arguments and return values are pickled, sent across a process boundary, and unpickled. Anything you hand a process worker (the function and its arguments) must be picklable, which rules out lambdas and locally-defined closures. Keep worker functions at module top level.

Practical tips and common pitfalls

Right-size your pool. For threads doing I/O, you can comfortably run far more workers than you have cores, since they're mostly waiting — dozens is normal. For a process pool, more workers than CPUs just adds overhead.

Don't over-parallelize tiny tasks. Spawning a process to add two numbers costs more than it saves. Batch small units of work, and reserve ProcessPoolExecutor for jobs where the computation clearly dwarfs the pickling overhead.

Always consume your results. The most common real-world bug is fire-and-forget: submitting work and never calling result(), so exceptions vanish. If you truly don't care about a return value, at least loop over the futures and call result() to surface failures.

Reach for threads first when unsure. If your bottleneck is the network or the disk — which it usually is for web scraping, API clients, and file processing — ThreadPoolExecutor is simpler, has no pickling constraints, and no __main__ guard requirement.

Wrap-up and next steps

The whole module reduces to a small, learnable surface: pick ThreadPoolExecutor for I/O-bound work and ProcessPoolExecutor for CPU-bound work; use map() when you want ordered results from a uniform call, and submit() plus as_completed() when you want results as they land; always call result() so errors can't hide; and add timeouts wherever "forever" isn't an acceptable wait. That handful of ideas covers the vast majority of parallel workloads you'll write.

From here, two natural next steps. If your work is I/O-bound and you want to push concurrency even higher on a single thread, look at asyncio, which trades the thread pool for cooperative coroutines. And if you're doing heavy numeric work, remember that libraries like NumPy already release the GIL internally, so a thread pool can sometimes give you real parallelism without ever touching a process. Start by taking one slow, sequential loop in your own code, wrapping it in a ThreadPoolExecutor, and measuring the difference — it's often a five-line change for a several-times speedup.