concurrent.futures provides a high-level, uniform interface for running
callables asynchronously. An Executor manages a pool of workers and
hands you back Future objects representing pending results — and the same
API works whether the workers are threads or processes, so you can swap one for
the other with a one-line change.
from concurrent.futures import ThreadPoolExecutor
def work(x):
return x * 2
with ThreadPoolExecutor(max_workers=4) as ex: # context manager auto-shuts down
future = ex.submit(work, 10) # schedule the call
print(future.result()) # 20
# swap to processes by changing only the class name:
# with ProcessPoolExecutor() as ex: ...
The context manager (with) cleanly handles worker shutdown and waits for
pending work on exit. Rule of thumb: prefer concurrent.futures over raw
threading/multiprocessing when you just want to run a function over a pool
and collect results.
Both share the Executor API but differ in workers. ThreadPoolExecutor runs
tasks in threads within one process — cheap, shared memory, but bound by the
GIL, so it only helps I/O-bound work. ProcessPoolExecutor runs tasks
in separate processes, each with its own GIL, giving true parallelism for
CPU-bound work (at the cost of pickling arguments/results).
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# I/O-bound: many network/disk waits -> threads
with ThreadPoolExecutor() as ex:
ex.map(download, urls)
# CPU-bound: number crunching -> processes
with ProcessPoolExecutor() as ex:
ex.map(crunch, datasets)
Because the API is identical, you can prototype with threads and switch to
processes if the GIL becomes the bottleneck. Rule of thumb: I/O-bound ->
ThreadPoolExecutor; CPU-bound -> ProcessPoolExecutor.
submit(fn, *args) schedules fn to run in the pool and immediately
returns a Future — a handle to a result that may not exist yet. The Future
lets you check status (done(), running()), block for the result
(result()), retrieve an exception (exception()), cancel(), or attach a
callback (add_done_callback).
from concurrent.futures import ThreadPoolExecutor
def slow_double(x):
return x * 2
with ThreadPoolExecutor() as ex:
fut = ex.submit(slow_double, 21) # returns instantly
print(fut.done()) # False — probably still running
print(fut.result()) # 42 — blocks until ready
result(timeout=...) blocks (up to an optional timeout) until the value is
ready. A Future decouples starting the work from collecting it, which is
what makes overlapping multiple calls possible.
executor.map(fn, iterable) is the convenient bulk form: it applies fn to
every item concurrently and returns an iterator of results in input order —
analogous to the built-in map, but parallel. submit is lower level,
giving you a Future per call for fine-grained control.
from concurrent.futures import ThreadPoolExecutor
def fetch(url):
return len(url)
urls = ["a", "bb", "ccc"]
with ThreadPoolExecutor() as ex:
for result in ex.map(fetch, urls): # results stream back in order
print(result) # 1, 2, 3
map is great when you have a clean iterable and want ordered results with
minimal code. Use submit (often with as_completed) when you need results
as they finish, per-task error handling, or cancellation. Note map raises
the first exception when you iterate to that result.
as_completed(futures) yields each Future as soon as it finishes,
regardless of submission order — so you can process results the moment they're
ready instead of waiting for the slowest task to keep its place (as map's
ordered output would).
from concurrent.futures import ThreadPoolExecutor, as_completed
def work(n):
return n * n
with ThreadPoolExecutor() as ex:
futures = [ex.submit(work, i) for i in range(5)]
for fut in as_completed(futures): # whichever finishes first
print(fut.result()) # order is non-deterministic
This is ideal for responsiveness — show progress as tasks complete, or
handle failures immediately. Use map when you want results in input order; use
as_completed when you want them in completion order.
An exception raised inside a worker is captured and stored in its Future,
not raised at submit time. It re-raises when you call future.result() (or
iterate to that item in map). You can also inspect it without raising via
future.exception().
from concurrent.futures import ThreadPoolExecutor
def boom(x):
raise ValueError(f"bad: {x}")
with ThreadPoolExecutor() as ex:
fut = ex.submit(boom, 1)
err = fut.exception() # returns the ValueError, doesn't raise
print(err) # bad: 1
fut.result() # NOW it re-raises ValueError
The implication: if you never call result() (or check exception()), an
error can pass silently. Always retrieve results — typically in a try/except
around result() — so worker failures surface in the main thread.
The with block calls shutdown(wait=True) on exit, which blocks
until all submitted work finishes and releases the worker threads/processes.
Without it you must call shutdown() yourself or risk the program exiting
with work still pending or threads leaking.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as ex:
futures = [ex.submit(work, i) for i in range(10)]
# <- here the pool is fully drained and cleaned up
# equivalent without `with`:
ex = ThreadPoolExecutor()
...
ex.shutdown(wait=True)
Rule of thumb: always use the with form so you never leak a pool or exit
before background work completes.
They differ. ThreadPoolExecutor defaults to min(32, os.cpu_count() + 4) — generous because threads are cheap and mostly used for I/O.
ProcessPoolExecutor defaults to os.cpu_count() — one worker per
core, since more processes than cores won't speed up CPU-bound work.
import os
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
ThreadPoolExecutor() # ~ min(32, cpu_count()+4) threads
ProcessPoolExecutor() # ~ cpu_count() processes
Rule of thumb: tune max_workers to the workload — high for I/O-bound
threads, roughly core-count for CPU-bound processes.
timeout caps how long you'll wait when iterating results — if exceeded,
iteration raises TimeoutError. chunksize (ProcessPoolExecutor only)
batches that many items per task sent to a worker, cutting pickling/IPC
overhead for large inputs; it has no effect on ThreadPoolExecutor.
with ProcessPoolExecutor() as ex:
# send work in batches of 100 -> far fewer IPC round trips
for r in ex.map(f, big_iterable, chunksize=100):
...
Rule of thumb: for many small CPU tasks, raise chunksize to amortize
per-task overhead; profile to find a good value.
Only if it hasn't started running. future.cancel() returns True and
cancels a task still queued, but returns False once a worker has picked
it up — there is no preemption of running work. future.cancelled()
tells you the outcome.
with ThreadPoolExecutor(max_workers=1) as ex:
a = ex.submit(slow) # starts immediately
b = ex.submit(slow) # queued behind a
b.cancel() # True -> b never runs
a.cancel() # False -> already running
Rule of thumb: cancel() only helps for not-yet-started tasks; to stop
running work you need your own cooperative flag/event.
It registers a function that fires when the Future completes (success,
exception, or cancellation), receiving the Future as its argument. It lets
you react to results without blocking on result(). The callback runs in
the thread that completed the future, so keep it quick.
def on_done(fut):
if fut.exception():
log.error(fut.exception())
else:
print("got", fut.result())
fut = ex.submit(work)
fut.add_done_callback(on_done) # called automatically when finished
Rule of thumb: use callbacks for fire-and-forget reactions; use
as_completed/result() when you need to gather results in the main flow.
concurrent.futures is thread/process-based with blocking
Future.result(). asyncio is single-threaded with awaitable
asyncio.Future. They bridge via loop.run_in_executor(pool, fn) (or
asyncio.to_thread), which runs a blocking function in a concurrent.futures
pool and hands back an awaitable.
import asyncio
from concurrent.futures import ProcessPoolExecutor
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_heavy, data)
Rule of thumb: use a ProcessPoolExecutor via run_in_executor to offload
CPU-bound work from an asyncio program without blocking the loop.
A worker that blocks on a Future from the same bounded pool can deadlock: if all workers are busy waiting on tasks that can't be scheduled (no free workers), nothing progresses. This is a classic with a single-worker or fully saturated pool.
with ThreadPoolExecutor(max_workers=1) as ex:
def outer():
return ex.submit(inner).result() # waits for a worker that's "me"
ex.submit(outer).result() # deadlock
Rule of thumb: don't have pool tasks block on results from the same pool — restructure the work or use a separate pool / different design.
initializer/initargs run a setup function once per worker when it
starts, before it handles any task. It's used to set up expensive per-worker
state — a DB connection, a model, a logging config — that you don't want to
recreate on every call.
def setup(dsn):
global conn
conn = connect(dsn) # one connection per worker
with ProcessPoolExecutor(initializer=setup, initargs=(dsn,)) as ex:
ex.map(query, ids) # each worker reuses its own conn
Rule of thumb: use initializer for one-time, per-worker resource setup
instead of building it inside every task.
wait(futures, return_when=...) blocks until a condition is met and
returns (done, not_done) sets. The options are ALL_COMPLETED (default
— wait for everything), FIRST_COMPLETED (return as soon as any finishes),
and FIRST_EXCEPTION (return when one raises, else all done).
from concurrent.futures import wait, FIRST_COMPLETED
done, pending = wait(futures, timeout=5, return_when=FIRST_COMPLETED)
for f in done:
print(f.result()) # handle whichever finished first
Rule of thumb: use as_completed to stream results in completion order, and
wait(return_when=...) when you need "first done" or "first error" semantics.
More Concurrency & Parallelism interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.