Skip to content

concurrent.futures Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on concurrent.futures: the Executor abstraction, ThreadPoolExecutor vs ProcessPoolExecutor, submit and Future objects, map, as_completed, and exception handling.

Read the in-depth guidePython concurrent.futures Explained — ThreadPoolExecutor, ProcessPoolExecutor, and Futures(opens in new tab)
15 of 15

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel