🐍 Python Course · Stage 3 · Lesson 55 of 89 · concurrent.futures — Parallelism
Course Overview →

🔵 Read Beginner and Intermediate tabs. The Expert tab has the internals — worth reading once you finish Stage 3.

← copy — Copy Objects queue — Thread-Safe Queues →
thecodex.expert · The Codex Family of Knowledge
Python Standard Library

concurrent.futures — High-Level Parallelism

concurrent.futures runs functions in parallel across threads or processes with one simple, uniform interface — the easiest way to speed up I/O-bound and CPU-bound work.

import concurrent.futures docs.python.org Last verified:
Canonical Definition

The concurrent.futures module provides a high-level interface for asynchronously executing callables using pools of threads (ThreadPoolExecutor) or processes (ProcessPoolExecutor). A Future object represents a pending result. The same API works for both executors, making it easy to switch between thread and process parallelism.

🟩 Beginner

Run functions in parallel, simply

What you will learn: the executor and map for parallel execution, and how a with-block manages the worker pool automatically.

How to read this tab: executor.map works like the built-in map but runs calls in parallel, returning results in input order.

⏱ 25 min📄 2 sections🔶 Prerequisite: Concurrency
The idea

Instead of doing slow tasks one after another, concurrent.futures runs many at once — across threads or processes — with almost no extra code. Submit work to a pool, and it hands back results as they finish. It is the simplest doorway into parallelism in Python.

💡

map returns results in input order, not finish order. executor.map yields results matching the input sequence, even though tasks complete out of order. If you want each result the moment it is ready, use submit with as_completed instead.

The executor and map

An executor manages a pool of workers. The easiest entry point is executor.map, which works like the built-in map but runs the calls in parallel.

Pythonexecutor_map.py
from concurrent.futures import ThreadPoolExecutor
import urllib.request

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

def fetch(url):
    with urllib.request.urlopen(url) as response:
        return url, len(response.read())

# Sequentially this would wait for each download in turn.
# With a thread pool, they download concurrently.
with ThreadPoolExecutor(max_workers=3) as executor:
    # map runs fetch on every url in parallel, yields results in order
    for url, size in executor.map(fetch, urls):
        print(f"{url}: {size} bytes")

# The 'with' block automatically waits for all tasks and shuts down
# the pool when done — no manual cleanup needed.
map returns results in input order

executor.map yields results in the same order as the inputs, even though the tasks finish in a different order. If you want results as soon as each one completes (not in order), use as_completed with submit, shown next.

💡

submit returns a Future immediately; result() blocks for the value. A Future represents a pending result — check done(), set a timeout on result(), and note that if the task raised an exception, result() re-raises it. Pair submit with as_completed for per-task error handling.

Futures and submit

Pythonfutures.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def slow_task(n):
    time.sleep(n)            # simulate work taking n seconds
    return f"task {n} done"

with ThreadPoolExecutor(max_workers=4) as executor:
    # submit() schedules one call and returns a Future immediately
    futures = [executor.submit(slow_task, n) for n in [3, 1, 2]]

    # as_completed yields each future as soon as it finishes
    # (so the 1-second task comes back first, not the 3-second one)
    for future in as_completed(futures):
        print(future.result())     # blocks until THIS future is ready

    # A Future also lets you check status and handle errors
    fut = executor.submit(slow_task, 1)
    print(fut.done())              # False (probably still running)
    result = fut.result(timeout=5) # wait up to 5s; raises on timeout
    # If the task raised an exception, .result() re-raises it here:
    # try:
    #     fut.result()
    # except SomeError as e:
    #     handle(e)

✅ Beginner tab complete

  • I can run tasks in parallel with ThreadPoolExecutor and map
  • I know the with-block waits for all tasks and shuts down the pool
  • I know map returns results in input order
  • I can fetch multiple URLs concurrently

Continue to queue →

🔵 Intermediate

Futures, threads vs processes

What you will learn: submit and Future objects, as_completed for results in finish order, and the crucial I/O-bound vs CPU-bound choice driven by the GIL.

How to read this tab: The deciding question: is your task waiting (I/O -> threads) or computing (CPU -> processes)?

⏱ 30 min📄 2 sections🔶 Prerequisite: Beginner tab

I/O-bound -> threads; CPU-bound -> processes. The GIL lets only one thread run Python bytecode at a time, so threads give no speedup for computation. Use ThreadPoolExecutor when tasks wait (network, disk) and ProcessPoolExecutor when they compute. The API is identical, so switching is one line.

Threads vs processes — the crucial choice

The two executors look identical but suit opposite workloads. The deciding factor is Python's Global Interpreter Lock (GIL), which lets only one thread run Python bytecode at a time.

Pythonthreads_vs_processes.py
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import math

# ThreadPoolExecutor — best for I/O-BOUND work
# (network requests, file reads, database queries)
# While one thread waits for I/O, others run. The GIL is released
# during I/O, so threads genuinely overlap waiting time.

# ProcessPoolExecutor — best for CPU-BOUND work
# (heavy computation, number crunching, image processing)
# Each process has its own interpreter and GIL, so they run on
# multiple CPU cores in true parallel.

def cpu_heavy(n):
    return sum(math.isqrt(i) for i in range(n))

numbers = [10_000_000] * 8

# For CPU-bound work, processes use all cores — threads would not help
# because the GIL serialises Python bytecode execution.
with ProcessPoolExecutor() as executor:        # defaults to os.cpu_count()
    results = list(executor.map(cpu_heavy, numbers))
    print(f"Computed {len(results)} results across CPU cores")

# Rule of thumb:
#   Waiting on I/O?  -> ThreadPoolExecutor
#   Burning CPU?     -> ProcessPoolExecutor
#   The API is identical, so switching is a one-line change.
The GIL is why this choice matters

Python's Global Interpreter Lock means threads cannot run Python bytecode simultaneously — so for CPU-bound work, threads give no speedup. Processes sidestep the GIL by each having their own interpreter. For I/O-bound work, threads are ideal because the GIL is released while waiting.

ProcessPoolExecutor needs picklable args and a main guard. Arguments and return values are sent between processes, so they must be picklable — no lambdas or local functions, use module-level ones. On Windows/macOS, wrap process-pool code in if __name__ == "__main__": or it recursively spawns.

Patterns and pitfalls

Pythonpatterns.py
from concurrent.futures import ThreadPoolExecutor, as_completed

# Pattern: map inputs to futures so you know which result is which
def process(item):
    return item * 2

items = ["a", "b", "c"]
with ThreadPoolExecutor() as executor:
    # A dict mapping each future back to its input
    future_to_item = {executor.submit(process, item): item for item in items}
    for future in as_completed(future_to_item):
        item = future_to_item[future]
        try:
            result = future.result()
            print(f"{item} -> {result}")
        except Exception as e:
            print(f"{item} failed: {e}")     # one task failing won't stop others

# PITFALL: ProcessPoolExecutor must run inside if __name__ == "__main__"
# on Windows/macOS (spawn start method), or it recursively spawns.
# def main():
#     with ProcessPoolExecutor() as ex:
#         ...
# if __name__ == "__main__":
#     main()

# PITFALL: arguments and return values for ProcessPoolExecutor must be
# picklable (they're sent between processes). Lambdas and local
# functions can't be pickled — use module-level functions.
Commonly confused
map vs submit + as_completed. Use map when you want results in input order and a clean loop. Use submit + as_completed when you want each result the moment it is ready, or need per-task error handling and status checks.
I/O-bound vs CPU-bound. If your task spends its time waiting (network, disk), it is I/O-bound — use threads. If it spends its time computing, it is CPU-bound — use processes. Choosing wrong (threads for CPU work) gives no speedup because of the GIL.

✅ Intermediate tab complete

  • I can submit tasks and get Future objects
  • I use as_completed for results as soon as they finish
  • I use ThreadPoolExecutor for I/O-bound, ProcessPoolExecutor for CPU-bound
  • I know the GIL is why CPU-bound work needs processes, not threads

Continue to queue →

🔴 Expert

Future internals and the GIL’s future

What you will learn: callbacks, cancel, exception, the wait function, and how free-threading (PEP 703) may change the threads-vs-processes calculus.

How to read this tab: Read to understand the full Future API and where Python concurrency is heading.

⏱ 20 min📄 1 section🔶 Prerequisite: After Stage 4

Future internals, the GIL’s future, and choosing the right tool

A Future is a synchronisation primitive representing a computation that may not have completed. Beyond result() and done(), it supports add_done_callback to register functions that fire on completion, cancel() to cancel not-yet-started tasks, and exception() to retrieve a raised exception without re-raising it. The wait function offers fine-grained control over waiting for first-completion, first-exception, or all-completed across a set of futures.

Pythonfutures_advanced.py
from concurrent.futures import (ThreadPoolExecutor, wait,
                                FIRST_COMPLETED, ALL_COMPLETED)

with ThreadPoolExecutor() as executor:
    futures = [executor.submit(pow, 2, n) for n in range(5)]

    # Callbacks fire when a future completes (in a worker thread)
    futures[0].add_done_callback(lambda f: print("first done:", f.result()))

    # wait() with return_when gives precise control
    done, not_done = wait(futures, timeout=10, return_when=ALL_COMPLETED)
    print(f"{len(done)} completed, {len(not_done)} pending")

    # exception() retrieves an error without raising it
    for f in done:
        err = f.exception()        # None if successful
        if err:
            print(f"failed with {err}")

# initializer runs once per worker — useful for per-worker setup
def init_worker():
    # e.g. open a per-process database connection or set up logging
    pass

with ThreadPoolExecutor(max_workers=4, initializer=init_worker) as ex:
    ...

# chunksize matters for ProcessPoolExecutor.map with many small tasks:
# batching reduces inter-process overhead
# with ProcessPoolExecutor() as ex:
#     ex.map(func, big_iterable, chunksize=100)

# InterpreterPoolExecutor (3.14+) — sub-interpreters, each with its own
# GIL, giving process-like parallelism with lower overhead than processes.

The concurrency landscape is shifting. PEP 703 introduced an experimental free-threaded build of CPython (3.13+) that can disable the GIL entirely, which would let ThreadPoolExecutor achieve true multi-core parallelism for CPU-bound work — historically the exclusive domain of ProcessPoolExecutor. Until free-threading is the default and the ecosystem adapts, the established guidance holds: ThreadPoolExecutor for I/O-bound concurrency, ProcessPoolExecutor for CPU-bound parallelism, and asyncio for very high-concurrency I/O with explicit async/await control. The strength of concurrent.futures is that its uniform Executor interface makes moving between thread and process pools a one-line change, letting you defer and revise the threads-versus-processes decision as you measure where the real bottleneck lies.

✅ Expert tab complete

  • I can use add_done_callback, cancel, and exception on a Future
  • I can use wait with return_when for fine-grained control
  • I know ProcessPoolExecutor args/returns must be picklable
  • I know free-threading (PEP 703) may let threads do true CPU parallelism

Continue to queue →

Sources

1
Python Standard Library — concurrent.futures. docs.python.org/3/library/concurrent.futures.html.
2
Python Standard Library — ProcessPoolExecutor. docs.python.org/3/library/concurrent.futures.html.
3
PEP 3148 — futures: execute computations asynchronously. peps.python.org/pep-3148/.
4
PEP 703 — Making the Global Interpreter Lock Optional in CPython. peps.python.org/pep-0703/.
Source confidence: High Last verified: Primary source: docs.python.org concurrent.futures