concurrent.futures sits above both threading and multiprocessing, offering one consistent Executor/Future API for both. This lesson shows how that abstraction lets you write submission and result-collection code once, and choose the underlying concurrency model ā threads or processes ā independently.
1One Interface, Two Genuinely Different Backends
concurrent.futures.ThreadPoolExecutor and ProcessPoolExecutor both implement the same Executor interface: .submit(func, *args) schedules func(*args) to run (on a worker thread or a worker process, respectively) and immediately returns a Future object representing that not-yet-complete computation; future.result() blocks until the computation finishes and returns its value (or re-raises its exception). This is deliberately the same shape for both backends ā the *only* thing that changes between the two code blocks in the first scene is which Executor subclass you instantiate.
This matters because, as the threading and multiprocessing lessons established, the right backend depends entirely on whether the work is I/O-bound (threads, gated by the GIL but fine since they spend most time waiting) or CPU-bound (processes, to genuinely bypass the GIL). With concurrent.futures, that decision is isolated to a single line ā ThreadPoolExecutor(max_workers=4) versus ProcessPoolExecutor(max_workers=4) ā while the surrounding code that submits work and processes results stays identical, making it straightforward to benchmark both and pick empirically, or to change the decision later as a workload's characteristics shift.
This is a meaningfully higher-level abstraction than using threading.Thread or multiprocessing.Process directly: Executor manages the pool of workers, queuing, and result collection for you, rather than requiring you to manually create, start, and join individual thread/process objects yourself.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fetch(url): ... # I/O-bound
def cpu_heavy(n): ... # CPU-bound
with ThreadPoolExecutor(max_workers=4) as pool:
future = pool.submit(fetch, "https://example.com")
result = future.result() # blocks until fetch() completes
with ProcessPoolExecutor(max_workers=4) as pool:
future = pool.submit(cpu_heavy, 20_000_000)
result = future.result() # SAME .submit()/.result() pattern, different backendIdentical pattern ā ThreadPoolExecutor or ProcessPoolExecutor
2as_completed(): Processing Results as They Finish, Not as They Were Submitted
Submitting several tasks to an executor and then simply calling .result() on each Future in submission order means you're effectively waiting for them *in that same order* ā if the first-submitted task happens to be the slowest, every result you'd already have from faster tasks sits unprocessed until that slow one finally completes. concurrent.futures.as_completed(futures) fixes this by yielding each Future from the given collection *as soon as it finishes*, regardless of submission order ā a genuinely different iteration pattern than iterating the futures list directly.
The common idiom ā futures = {pool.submit(fetch, url): url for url in urls}, mapping each Future back to metadata about what it represents ā is worth internalizing specifically because Future objects themselves don't carry that context; as_completed gives you back the Future, and you look up which URL it corresponds to via the dict you built at submission time. This pattern (a dict keyed by Future, valued by whatever identifying info you need) shows up constantly in real concurrent.futures code for exactly this reason.
This matters practically whenever tasks have meaningfully different completion times ā processing results as they arrive (updating a progress bar, streaming partial results to a UI, short-circuiting once you have 'enough' results) is a materially better experience than waiting for the single slowest task before processing anything at all.
from concurrent.futures import ThreadPoolExecutor, as_completed
urls = ["https://a.com", "https://b.com", "https://c.com"]
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(fetch, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
print(f"{url} finished: {future.result()}") # prints in COMPLETION orderYields each Future the moment IT finishes, not in submission order
3Clean Exception Propagation Across the Thread/Process Boundary
A subtle but important correctness property concurrent.futures provides: an exception raised inside a task running on a worker thread or process doesn't crash silently, get lost, or need manual capturing ā it's stored on the Future object, and re-raised automatically the moment calling code invokes future.result(), at that exact call site, exactly as if the exception had occurred there directly. Wrapping future.result() in an ordinary try/except catches it precisely the way you'd expect from synchronous code, no special cross-thread or cross-process exception-handling machinery required on your part.
This is a real ergonomic and correctness win over manually managing raw threading.Thread or multiprocessing.Process objects, where a worker's unhandled exception can be genuinely easy to lose entirely ā a raw Thread's target function raising simply prints a traceback to stderr and terminates that thread, with no automatic mechanism for the main thread to detect or re-raise it unless you build that plumbing yourself.
This exception-propagation behavior holds identically for both ThreadPoolExecutor and ProcessPoolExecutor, despite processes requiring the exception itself to be pickled and sent back across the process boundary (the same serialization mechanism covered in the Multiprocessing lesson) ā concurrent.futures handles that transport transparently, so from the calling code's perspective, error handling looks the same regardless of which backend you chose.
def risky_task():
raise ValueError("something broke")
with ThreadPoolExecutor() as pool:
future = pool.submit(risky_task)
try:
future.result()
except ValueError as e:
print(f"Caught from worker: {e}") # the exception crossed the thread boundary cleanlyRe-raises the worker's exception cleanly, in both thread and process backends
4Step-by-Step Breakdown
Every concurrency tool so far has its own distinct API. concurrent.futures gives threading and multiprocessing the SAME interface ā let's see why that's genuinely useful.
ThreadPoolExecutor and ProcessPoolExecutor share the EXACT SAME interface -- submit() returns a Future, regardless of which backend you're using.
Checkpoint: What is the main practical benefit of ThreadPoolExecutor and ProcessPoolExecutor sharing the same submit()/.result() interface?
- āYou can switch between thread-based and process-based concurrency by changing one line, without rewriting your submission/result-handling code
- āPython automatically picks whichever backend is faster for you
as_completed() lets you process results in the order they FINISH, not the order you submitted them -- useful when tasks take varying amounts of time.
A Future also cleanly propagates exceptions from the worker -- calling .result() re-raises whatever exception the task raised, instead of silently swallowing it.
Checkpoint: If risky_task() (running in a worker thread) raises ValueError, what happens when the calling code calls future.result()?
- āThe ValueError is re-raised in the calling code at the .result() call, exactly as if it happened there directly
- āThe exception is silently discarded, and .result() returns None
That completes Concurrency & Parallelism ā threads, processes, asyncio, and the unified Executor API give you the full toolkit for both I/O-bound and CPU-bound work. Next, we go deeper into the standard library modules every professional Python codebase relies on.
Use a Real Shared Executor Interface. Finish run_with_executor(): submit()/.result() work identically across executor backends.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Write submission/result-handling code against the Executor interface, choosing the backend last
Since ThreadPoolExecutor and ProcessPoolExecutor share an identical interface, structuring code this way lets you swap I/O-bound vs CPU-bound strategy with a one-line change instead of a rewrite.
Use as_completed() with a Future-to-metadata dict whenever task durations vary meaningfully
Processing results as they arrive, rather than in submission order, avoids unnecessarily blocking on a slow early task while faster later results sit ready and unprocessed.
Frequent Bugs
Submitting many tasks to an executor and never calling .result() on their futures at all, silently discarding any exceptions those tasks raised.
Always eventually call .result() on every submitted Future (directly, or via as_completed()) so any exception a task raised is actually surfaced and handled, rather than silently disappearing.
Real-World Examples
Processing Results as They Arrive From Multiple Slow API Calls
A monitoring tool checks the health of 20 services with varying response times, and wants to report each service's status to a dashboard as soon as its check completes, rather than waiting for the single slowest service.
from concurrent.futures import ThreadPoolExecutor, as_completed
def check_health(service_url: str) -> dict:
response = requests.get(f"{service_url}/health", timeout=5)
return {"url": service_url, "status": response.status_code}
with ThreadPoolExecutor(max_workers=20) as pool:
futures = {pool.submit(check_health, url): url for url in service_urls}
for future in as_completed(futures):
result = future.result()
update_dashboard(result) # updates AS results arrive