๐Ÿ”ฎ Pattern 12: Futures Deep Dive

A Future is a receipt for a result that isn't ready yet. Master it and orchestrating dozens of concurrent tasks becomes calm and readable.

What a Future Actually Is

When you submit() work to a ThreadPoolExecutor, you don't block โ€” you get a Future back immediately. It's a placeholder object that will eventually hold the worker's return value (or the exception it raised). You hold onto it and ask, later, "are you done?" or "give me the result."

Key Insight: submit() returns instantly with a Future; future.result() is where you actually block and wait. Separating "start the work" from "collect the result" is the whole point โ€” it's what lets many tasks run at once.

The Future Lifecycle

stateDiagram-v2 [*] --> Pending: submit() Pending --> Running: a worker picks it up Running --> Done: returned a value Running --> Done: raised an exception Pending --> Cancelled: cancel() before it starts Done --> [*]: result() / exception() Cancelled --> [*]
from concurrent.futures import ThreadPoolExecutor
import time

def slow_square(n):
    time.sleep(0.5)
    return n * n

with ThreadPoolExecutor(max_workers=4) as pool:
    fut = pool.submit(slow_square, 6)   # returns NOW, work runs in background
    print(fut.done())                   # False โ€” still pending/running
    print(fut.result())                 # BLOCKS ~0.5s, then prints 36
    print(fut.done())                   # True

submit() vs executor.map()

Two ways to launch work. They differ in how โ€” and when โ€” you get results back.

# map: results come back in INPUT order, regardless of finish order
with ThreadPoolExecutor(max_workers=4) as pool:
    for result in pool.map(slow_square, [1, 2, 3, 4]):
        print(result)          # 1, 4, 9, 16 โ€” always this order
The sharp edge of map: because it yields in input order, a slow first item stalls iteration โ€” you can't consume item 2 until item 1 is ready, even if 2 finished long ago. And if any task raised, the exception surfaces only when you reach that item during iteration, aborting the loop. Great for order-preserving batch work; wrong when you care about latency.
Rule of thumb: use map when you need results aligned to inputs and every task is roughly equal cost. Use submit + as_completed when task durations vary and you want to react the moment each one lands.

as_completed(): Process Results as They Land

concurrent.futures.as_completed(futures) yields each future the instant it finishes โ€” in completion order, not submission order. This is the key to low latency: you start handling the fast results without waiting on the slow ones.

flowchart LR subgraph MAP["map() โ€” INPUT order"] M1["result 1"] --> M2["result 2"] --> M3["result 3"] MN["blocked on #1
even if #3 is ready"] end subgraph AC["as_completed() โ€” COMPLETION order"] A3["#3 finished first โ†’ yield #3"] --> A1["#1 โ†’ yield #1"] --> A2["#2 โ†’ yield #2"] end
from concurrent.futures import ThreadPoolExecutor, as_completed
import time, random

def work(n):
    time.sleep(random.uniform(0.1, 1.5))   # wildly varying durations
    return n, n * n

with ThreadPoolExecutor(max_workers=5) as pool:
    futures = {pool.submit(work, n): n for n in range(8)}   # map future -> input
    for fut in as_completed(futures):
        n = futures[fut]                    # recover which input this was
        _, squared = fut.result()           # already done, returns instantly
        print(f"n={n} finished first-available โ†’ {squared}")
# Output order tracks who FINISHED, not who was submitted.
Why this wins on latency: total wall time is the same, but you begin useful work on each result as early as physically possible. If you're streaming responses, updating a progress bar, or writing rows as they arrive, as_completed is almost always what you want. The {future: input} dict is the idiom for remembering which future was which.

wait(): When You Need Only Some Results

Sometimes you don't want to iterate everything โ€” you want to block until the first task finishes, or until all do, then move on. concurrent.futures.wait() does exactly that and returns two sets: done and not_done.

from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED, ALL_COMPLETED

with ThreadPoolExecutor(max_workers=4) as pool:
    futures = [pool.submit(work, n) for n in range(4)]

    # Block only until the FIRST one is done (e.g. "fastest mirror wins")
    done, not_done = wait(futures, return_when=FIRST_COMPLETED)
    print(f"{len(done)} finished, {len(not_done)} still going")

    # Now block for the rest
    done, not_done = wait(futures, return_when=ALL_COMPLETED)
    print(f"all {len(done)} done")
as_completed vs wait: as_completed streams futures one-by-one as they finish (you loop). wait blocks once for a condition (FIRST_COMPLETED or ALL_COMPLETED) and hands back sets. Use wait for "fire many, act on the first winner" or barrier-style joins; use as_completed to process every result as it arrives.

Results, Timeouts, and Exceptions

A worker's return value and its exceptions are both captured inside the future. Nothing is raised in a background thread and lost โ€” it's re-raised into your thread when you ask for the result.

def flaky(n):
    if n == 3:
        raise ValueError(f"n={n} is cursed")
    time.sleep(2)
    return n

with ThreadPoolExecutor(max_workers=4) as pool:
    fut_ok  = pool.submit(flaky, 1)
    fut_bad = pool.submit(flaky, 3)

    # result(timeout=...) raises TimeoutError if not ready in time
    try:
        print(fut_ok.result(timeout=0.5))    # raises TimeoutError (work takes 2s)
    except TimeoutError:
        print("not ready yet โ€” the task keeps running though")

    # The worker's exception is RE-RAISED here, in the main thread:
    try:
        fut_bad.result()
    except ValueError as e:
        print(f"caught from worker: {e}")

    # Or inspect without raising โ€” returns the exception object, or None:
    print(fut_bad.exception())               # ValueError('n=3 is cursed')
The classic silent-failure trap: if you submit() a future and never call .result() (or .exception()) on it, an exception raised inside the worker is swallowed โ€” no traceback, no crash, nothing. Always collect your futures. as_completed + .result() naturally forces every exception to surface.
On timeout semantics: result(timeout=0.5) raising TimeoutError does not cancel the task โ€” the worker keeps running in the background. Timeouts bound your waiting, not the work itself. There is no safe way to force-kill a running thread; design tasks to check a stop flag if you need cancellation.

add_done_callback(): React Without Blocking

Instead of polling or blocking on result(), you can attach a callback that fires the moment the future completes. The callback receives the future itself and runs in whichever thread finished the work (or immediately, in your thread, if the future is already done).

def on_done(fut):
    if fut.exception():
        print(f"  โœ— failed: {fut.exception()}")
    else:
        print(f"  โœ“ got {fut.result()}")

with ThreadPoolExecutor(max_workers=4) as pool:
    for n in range(4):
        pool.submit(flaky, n).add_done_callback(on_done)
    # callbacks fire asynchronously as each task lands โ€” no explicit waiting
Keep callbacks tiny and non-blocking. They run on a worker thread, so heavy work inside a callback ties up the pool. And a callback that itself raises won't crash your program โ€” the exception is logged and swallowed. Use callbacks for cheap fan-out (increment a counter, enqueue a follow-up, update a metric); use as_completed when you want the results back in your own control flow.

Key Takeaways

APIWhat it gives youReach for it whenโ€ฆ
submit()One Future, returns instantlyYou want per-task control
map()Results in input orderOrder matters, tasks are even
as_completed()Futures in finish orderLatency matters, durations vary
wait(...)done / not_done setsFirst-winner or barrier join
result(timeout=)Value, or re-raises the worker's errorCollecting a result (bounds YOUR wait)
exception()The error object, or NoneInspecting failure without raising
add_done_callback()Fire-and-forget on completionCheap async reactions
The one rule that saves you: every future you submit must eventually be collected (result, exception, as_completed, or a callback) โ€” otherwise worker exceptions vanish silently. Next up: why all this juggling exists in the first place โ€” the GIL.