The modern, high-level way to run many tasks concurrently โ and actually get results back.
Thread objects is
wasteful and fragile โ no result values, exceptions vanish into the void, and you hand-roll a queue and
shutdown logic every time (Pattern 3).
concurrent.futures.ThreadPoolExecutor solves all of that. It maintains a fixed
pool of reusable worker threads, feeds them tasks from an internal queue, and hands you back a
Future for each task โ an object that will eventually hold the result or the
exception.
With max_workers=2, only two tasks run at once; the rest wait in the queue and start as
workers free up. Each submit() immediately returns a Future, long before the
task actually runs.
with ThreadPoolExecutor() as ex:. On exit, the
with block calls ex.shutdown(wait=True) for you โ it blocks until every
submitted task has finished and cleanly joins the worker threads. No leaked threads, no forgotten
cleanup.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as ex:
ex.submit(do_work, arg)
# ... submit more ...
# <- block exits here: waits for ALL tasks, then joins the threads
submit() โ a Future
submit(fn, *args, **kwargs) schedules the call and hands you a Future. It
does not block. You interrogate the future later:
future.result() โ blocks until done, then returns the value. If the task raised, calling result() re-raises that exception here, in your thread.future.exception() โ returns the exception object (or None) without raising it.future.done() โ non-blocking status check.concurrent.futures.as_completed(futures) โ yields futures in the order they finish, not the order you submitted them. Great for streaming results as they land.from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(url):
... # returns data, or raises on failure
return len(url)
urls = ["a", "bb", "ccc"]
with ThreadPoolExecutor(max_workers=4) as ex:
future_to_url = {ex.submit(fetch, u): u for u in urls}
for future in as_completed(future_to_url): # first-to-finish first
url = future_to_url[future]
try:
result = future.result() # re-raises if fetch() failed
except Exception as exc:
print(f"{url} failed: {exc!r}")
else:
print(f"{url} -> {result}")
Future, the exception is captured and
re-raised the moment you call result() โ so you actually find out things broke.
executor.map(): The Simple Case
When you just want to apply one function to many inputs, map() is the concise choice. It
works like the built-in map() but runs the calls concurrently.
with ThreadPoolExecutor(max_workers=8) as ex:
results = ex.map(fetch, urls) # returns a lazy iterator
for r in results: # results come back IN INPUT ORDER
print(r)
map():
submit() + as_completed() when you need per-task error handling.import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
def process(item_id):
time.sleep(random.uniform(0.1, 0.5)) # simulate network / disk I/O
if item_id == 7:
raise ValueError("item 7 is cursed")
return item_id, item_id ** 2
items = range(20)
successes, failures = {}, {}
# I/O-bound โ a high worker count is fine; they mostly wait
with ThreadPoolExecutor(max_workers=10) as ex:
futures = {ex.submit(process, i): i for i in items}
for future in as_completed(futures):
item_id = futures[future]
try:
_, squared = future.result() # re-raises this task's error
except Exception as exc:
failures[item_id] = repr(exc)
else:
successes[item_id] = squared
print(f"{len(successes)} ok, {len(failures)} failed")
print("failures:", failures) # {7: "ValueError('item 7 is cursed')"}
max_workersmin(32, os.cpu_count() + 4) โ a sane starting point for I/O.
For CPU-bound work, use its sibling ProcessPoolExecutor, which has the identical API
(submit, map, Future) but runs tasks in separate processes,
sidestepping the GIL entirely.
from concurrent.futures import ProcessPoolExecutor
# Same API โ but real parallelism for CPU-heavy functions
with ProcessPoolExecutor() as ex:
results = ex.map(crunch_numbers, big_dataset)
ThreadPoolExecutor. Burning CPU in pure
Python โ ProcessPoolExecutor. The code barely changes because they share an interface.
| Feature | Why it beats raw threads |
|---|---|
submit() โ Future | Get results back; exceptions are captured, not lost |
future.result() | Blocks for the value; re-raises the task's exception |
as_completed() | Stream results in finish order, handle each failure |
map() | Concise; ordered results, but exceptions surface late |
with block | Pooling + clean shutdown (join) for free |
ProcessPoolExecutor | Same API, real parallelism for CPU-bound work |
Future handling
(chaining, callbacks with add_done_callback, cancellation, wrapping blocking calls in async
code), see Pattern 12: Futures.