Skip to content

← Concurrency Without Regret step 9 of 38

Medium Primitives

map_with_failures: partial success on a thread pool

Fan work out to a thread pool without letting one bad item take the batch with it.

def map_with_failures[T, R](
    fn: Callable[[T], R], items: Sequence[T], *, max_workers: int
) -> tuple[list[R], list[tuple[int, BaseException]]]:
  • Successes come back in input order.
  • Failures come back as (index, exception) pairs, ordered by index.
  • Every item is attempted; one raising must never stop the others.
  • Every exception is observed exactly once.
  • The pool is shut down on every exit path.

Note BaseException, not Exception. A worker that raises KeyboardInterrupt still has that exception stored on its future, and a map_with_failures that only catches Exception will drop it on the floor.

Why Executor.map cannot do this

map() raises the worker’s exception when you reach that element of the result iterator, and cancels the not-yet-started futures at that point. You get the first few results, an exception, and no record of which of the remaining items ran. Partial success is not expressible. submit plus a future-to-index mapping is.

The other trap map carries: it consumes the whole input iterable immediately, so a lazy generator is fully materialised before a single result appears. Python 3.14 added buffersize= for exactly that; on 3.12 there is no fix except chunking yourself.

What the report proves

  • values — input order. The driver’s fn sleeps longest for the earliest item, so completion order is the reverse of input order. Appending as futures complete fails here.
  • failures / failure_order — every failure captured once, keyed by input index, returned in index order.
  • attempted — equals the item count in every case, including the ones where everything fails. A short-circuit is visible here.
  • thread_delta — worker threads alive before minus after. Must be 0. A pool that is never shut down leaves its idle workers parked on the queue forever, and this is what catches it.

Where the type system earns its keep

Future[R] is generic, and submit is precisely typed: submit(fn, item) checks item against fn‘s parameter and gives you a Future[R] whose .result() is an R. Compare Thread(target=fn, args=(item,)) where args is effectively untyped — nothing checks the tuple against the target’s signature, and there is no result type at all because there is nowhere for a result to go.

Under --strict the future-to-index dict wants a real annotation (dict[Future[R], int]), and that annotation is what makes the two return lists provably homogeneous instead of list[Any].

Loading visualization…