AlgoMaster Logo

concurrent.futures (ThreadPoolExecutor, ProcessPoolExecutor)

Medium Priority18 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

The standard library has two low-level concurrency tools (threading and multiprocessing) and one high-level wrapper that sits on top of both: concurrent.futures. It gives you a single API where you submit work, hold a Future for each piece, and get results back when they finish. The same code shape works whether the workers are threads or processes, so you can switch between them by changing one class name.

This lesson covers the executor interface, the Future object, the helpers for waiting on many futures at once, and when to prefer this module over the raw threading and multiprocessing APIs.

Why concurrent.futures Exists

The raw threading.Thread and multiprocessing.Process / multiprocessing.Pool APIs both work, but they expose a lot of plumbing. You manage worker objects, call start() and join(), set up queues to ship results back, and write your own retry and timeout logic. None of that is hard, but it adds up across a real codebase.

concurrent.futures collapses all of that into one mental model. You ask an Executor to run a callable, and it hands you a Future you can wait on. The executor pools workers behind the scenes, recycles them across calls, propagates exceptions back to the caller, and gives you helpers for the two common questions you ask of a batch of work: "wait for all of them" and "wake me up as soon as any one finishes."

The picture is the same whether the workers are threads or processes. You submit, the executor dispatches to a worker, and a Future becomes the handle you use to read the result, check for errors, or cancel the work. Picking between threads and processes is a runtime decision (a different class), not an architectural one.

The module ships in the standard library as concurrent.futures. There's nothing to install.

The Executor Interface

Executor is the abstract base class for both pool types. The two concrete classes (ThreadPoolExecutor and ProcessPoolExecutor) share four methods you'll use constantly:

MethodWhat it does
submit(fn, *args, **kwargs)Schedule a single call. Returns a Future.
map(fn, *iterables, timeout=None, chunksize=1)Apply fn to each input. Returns an iterator of results, in input order.
shutdown(wait=True, cancel_futures=False)Stop accepting new work. Optionally wait for pending work to finish.
__enter__ / __exit__Context manager support. __exit__ calls shutdown(wait=True).

The context manager form is what you'll see in almost every real codebase. It guarantees the pool is cleaned up even when an exception is raised:

with opens the pool, submit schedules the call, and future.result() blocks until the worker is done and returns the value. When the with block exits, the pool waits for any outstanding work and shuts down. You never call shutdown() manually unless you have a long-lived pool that outlives a single function.

ThreadPoolExecutor

ThreadPoolExecutor runs your callables on a pool of OS threads. It's the right choice when your work is I/O-bound: HTTP requests, database queries, file reads, anything where the worker spends most of its time waiting for something outside Python. Threads share memory with the parent process, so you don't pay any pickling or serialization cost when you pass arguments or return values.

The max_workers parameter controls how many threads the pool keeps. The default since Python 3.8 is min(32, (os.cpu_count() or 1) + 4), which is tuned for I/O-bound work, not CPU-bound work.

A small e-commerce example: download every order PDF for a customer in parallel.

Five sequential time.sleep(0.5) calls would take 2.5 seconds. With five threads, they wait in parallel and finish in roughly half a second. The speedup comes from the fact that time.sleep (and any real I/O call) releases the GIL while it waits, so the other threads keep running.

ProcessPoolExecutor

ProcessPoolExecutor runs each callable in a separate OS process. It sidesteps the GIL completely because each process has its own Python interpreter, which means CPU-bound work actually gets parallel. The trade-off is that arguments and return values cross a process boundary, so they have to be pickled and unpickled. Anything you submit must be picklable: top-level functions, primitive arguments, picklable return values.

The default max_workers is os.process_cpu_count() on Python 3.13 and newer, and os.cpu_count() on older versions. The two usually agree, but process_cpu_count respects CPU affinity (the set of CPUs your process is allowed to use), which matters in containers and on shared servers.

A CPU-bound e-commerce example: compute the SHA-256 hash of every uploaded product image. Hashing big files in pure Python is a CPU-bound loop, so threads don't help. Processes do.

Two things to flag here. First, the if __name__ == "__main__": guard. On Windows and macOS, ProcessPoolExecutor uses the spawn start method, which re-imports the main module in each worker. Without the guard, the workers would also try to start a pool, recursively. Lesson 05 covered this in detail; the rule still applies here.

Second, the function hash_image is defined at module level. A nested function or a lambda would fail to pickle. The error looks like:

If you see that error, the fix is to move the function to module scope.

The Future Object

Every submit() call returns a concurrent.futures.Future. The Future is a handle to a piece of work that may or may not be done yet. You don't construct it directly; the executor does, and it manages the worker that fulfills it.

The methods you'll use most:

MethodWhat it does
result(timeout=None)Block until done, then return the value. Re-raises any exception the callable raised.
exception(timeout=None)Block until done, then return the exception (or None if the call succeeded).
done()Non-blocking check. True if the call finished, cancelled, or raised.
cancel()Try to cancel. Returns True only if the work hadn't started yet.
add_done_callback(fn)Register a callback that runs when the future is done. The callback receives the future.
running()True while the worker is actively executing the call.
cancelled()True if cancel() succeeded.

Here's a run that exercises most of them:

If the callable raises, the exception is captured and re-raised when you call result(). This is one of the biggest practical wins over raw threads. With threading.Thread, an exception inside the thread just prints a traceback and dies; the parent never sees it. With a Future, the exception comes back to the caller:

add_done_callback is useful when you want to react as work finishes without blocking. The callback runs on the thread that completed the work (for ThreadPoolExecutor) or on a thread in the calling process that drains results (for ProcessPoolExecutor). Keep callbacks short and don't put heavy work in them, otherwise you'll bottleneck the pool.

About cancel(): it only works if the future is still pending in the queue. Once a worker has picked the call up, you can't stop it from the outside. Cooperative cancellation (the worker checking a flag) is your only option then.

executor.map: Order-Preserving Parallel map

map(fn, iterable) is the simplest way to apply one function to a batch of inputs. It returns an iterator that yields results in the same order as the inputs, regardless of which workers finished first.

Two behaviors to remember. If any worker raises, the exception comes out of the iterator at the point that input would have been yielded. The iterator stops there and the remaining results are dropped. If you need to keep processing after a failure, use submit() plus as_completed() instead.

The chunksize parameter is a ProcessPoolExecutor optimization. By default each input is sent to the worker pool individually, which costs one IPC round-trip per item. For very fast-per-item work, chunksize=N groups inputs into chunks of N so each round-trip carries more work. ThreadPoolExecutor ignores this argument because threads share memory.

The timeout argument is per-iteration: if the next result isn't ready within timeout seconds, you get a TimeoutError. The workers keep running in the background; only the iteration is timed out.

submit + as_completed: Results as They Finish

map() is order-preserving, which is what you want most of the time. But sometimes you'd rather see results in the order they finish, so you can stream-process them or react to the fastest ones first. That's what as_completed is for.

The pattern is: submit each input separately (you get a list of futures), then hand that list to as_completed. It yields each future as it finishes. Notice the function returns (competitor, price), so the caller knows which input each result came from. With map, the input order is the key. With as_completed, you typically encode the key into the result.

as_completed also takes a timeout. If timeout seconds elapse before the next future finishes, it raises concurrent.futures.TimeoutError. The remaining futures keep running.

A common pattern is to map futures to their inputs with a dict, so you can include the input in error messages:

This is the workhorse pattern for "fire off a bunch of network requests and handle each result as it arrives." Failures don't stop the rest of the batch, because each future is independent.

wait: Coarse Control over Batches

When you want to wait for "all of them" or "any one of them" or "until one of them fails," and you don't care about iterating results one by one, concurrent.futures.wait is the tool.

The signature is wait(futures, timeout=None, return_when=ALL_COMPLETED). It returns a named tuple with two sets: done (futures that have finished) and not_done (futures still running or queued).

return_whenWakes up when...
ALL_COMPLETED (default)Every future is done.
FIRST_COMPLETEDAt least one future is done.
FIRST_EXCEPTIONAt least one future has raised, or all finished without raising.

FIRST_COMPLETED is good for "race" patterns where you only want the fastest answer:

The two still-running futures keep running in the background. If you don't need them, calling cancel() on each one will stop the ones still queued. The pool's __exit__ will wait for the ones already running.

FIRST_EXCEPTION is useful for "fail fast" batches: do as much work as possible in parallel, but if any one of them fails, stop adding to the queue and surface the error.

When to pick wait vs as_completed: pick as_completed when you want to process results one by one as they arrive. Pick wait when you want a single decision point ("everything done?", "anyone done yet?", "anyone failed?") and don't need the streaming behavior.

Switching Pools by Swapping One Class

The whole point of concurrent.futures is that the surface area doesn't change between threads and processes. The same code that downloads PDFs over HTTP can hash image files in parallel by changing one line.

The function run_parallel is parameterized over the executor class. Threads for the network work, processes for the hashing work. The body of run_parallel never changes, and neither does either worker function. That is the unified API in one snapshot.

The decision rule is straightforward: if the work spends most of its time waiting on I/O (network, disk, database), use ThreadPoolExecutor. If it spends most of its time running Python bytecode (numerical loops, parsing, compression, hashing), use ProcessPoolExecutor. Lesson 11 covers the full decision matrix and the edge cases.

How This Compares to multiprocessing.Pool

Lesson 07 covered multiprocessing.Pool, which is the older API for parallelizing CPU-bound work over processes. ProcessPoolExecutor and multiprocessing.Pool are spiritually similar (both spawn a pool of worker processes), but the APIs differ in ways that matter day to day.

Featuremultiprocessing.Poolconcurrent.futures.ProcessPoolExecutor
ReturnsPlain values or AsyncResultFuture objects
Map functionpool.map, pool.imap, pool.imap_unordered, pool.starmappool.map
Submit one taskpool.apply_async(fn, args)pool.submit(fn, *args)
Wait helpersNone (use AsyncResult.wait per task)as_completed, wait
Mirror class for threadsNo (multiprocessing.pool.ThreadPool exists but is undocumented)Yes, ThreadPoolExecutor
Exception in workerRe-raised when you call .get()Re-raised when you call .result()
Context managerYesYes

The modern preference in most Python codebases is concurrent.futures. Three reasons:

  1. One mental model across threads and processes. With multiprocessing.Pool plus threading.Thread, you're learning two unrelated APIs. With executors, you learn one.
  2. `Future` is a first-class object. You can pass it around, attach callbacks, check status, and aggregate it with as_completed or wait. AsyncResult is similar but less ergonomic.
  3. Cleaner exception propagation. Errors come back to the caller through .result(), with a normal traceback. No surprise silent failures.

multiprocessing.Pool still has things ProcessPoolExecutor doesn't: imap_unordered for memory-efficient streaming, starmap for unpacking tuples, the initializer argument for per-worker setup (ProcessPoolExecutor has this too as of 3.7), and a few advanced controls. Reach for it when you specifically need those features. Otherwise, default to concurrent.futures.

If you're working with asyncio, there's a small but useful bridge: loop.run_in_executor(executor, fn, *args) runs a synchronous function in a thread or process pool and returns an asyncio.Future you can await. That's the cleanest way to run blocking code from an async function without freezing the event loop. The async lessons (08 and 09) covered the async side; this is just to flag that the bridge exists.

Quiz

concurrent.futures Quiz

10 quizzes