Skip to content
← Back to explainers

Python explainer

Run a Big Batch on a Thread Pool

About 4 min to read. Allow extra time to work through examples.

Know which jobs in a batch failed, limit the queue, size the pool, avoid a pool that waits on itself, and shut down cleanly.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your script sends 500 welcome emails through a thread pool:

with ThreadPoolExecutor(max_workers=8) as pool:
    for receipt in pool.map(send_email, addresses):
        save(receipt)

Address 40 is invalid, and send_email raises. As you saw earlier in this track, pool.map raises that error when the loop reaches email 40, and the loop stops. Now ask what happened to emails 41 to 499. Some were already sent, some were still being sent, and the rest were cancelled and never ran. The exception can't tell you which is which, so you can't safely run the batch again without sending some emails twice.

Record an outcome for every item

Give each item an index, its position in the input. Submit every item, keep a dictionary from each future to its index, and read every future:

from concurrent.futures import Future, ThreadPoolExecutor, as_completed

items = ["10", "bad", "10"]
values: dict[int, int] = {}
errors: dict[int, Exception] = {}

with ThreadPoolExecutor(max_workers=2) as pool:
    jobs: dict[Future[int], int] = {
        pool.submit(int, item): index for index, item in enumerate(items)
    }
    for future in as_completed(jobs):
        index = jobs[future]
        try:
            values[index] = future.result()
        except ValueError as error:
            errors[index] = error

print(sorted(values.items()))  # [(0, 10), (2, 10)]
print(sorted(errors))          # [1]

Key the outcomes by index, not by item. Two items can be equal, like the two "10" strings here, and the second would overwrite the first. sorted puts the outcomes back in input order, whatever order the jobs finished in.

With every outcome recorded, a retry can rerun only the items that failed.

Check your understanding pool.map raised on email 40 of 500. Which emails were sent? Show answer

You can't tell from the exception. Later emails may have finished, may still be running, or may have been cancelled when the loop stopped. To know each item's outcome, submit each one, keep its index, and read every future.

Catch everything a job can raise

A future stores whatever its job raised, including KeyboardInterrupt and SystemExit. Those two are subclasses of BaseException rather than Exception, so except Exception around result() lets them escape and ends your loop early. When your code must report on every item, use future.exception(). It waits like result(), but it returns the job's exception instead of raising it, or None if the job succeeded.

Recording a job's KeyboardInterrupt does not hide a real Ctrl+C. Python raises that one in your main thread, not in a pool worker.

Limit what you queue

max_workers=8 limits how many jobs run at the same time. It does not limit how many wait in the queue. pool.map and a submit loop both queue every item straight away, so a million items become a million futures in memory. For a large or endless input, submit in batches. Or cap the number of unfinished futures, and submit a new item each time one finishes.

Pick max_workers to match the resource the jobs share. If they all talk to a database that allows 10 connections, 50 workers means 40 of them wait for a connection. Measure a few sizes, and use the smallest one that is fast enough.

Give nested work its own pool

from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(max_workers=1)


def outer() -> int:
    inner = pool.submit(lambda: 1)
    return inner.result()  # waits for a free worker, but outer is using it


pool.submit(outer).result()  # never returns

outer holds the only worker while it waits for inner, and inner needs a worker to run. Neither can finish. With more workers, the same thing happens once every worker is waiting on an inner job. Give nested work its own pool, or change the jobs so that none of them waits on its own pool.

Shutdown waits for running jobs

Leaving the with block calls pool.shutdown(wait=True), which waits for every submitted job. Two options change that:

  • shutdown(cancel_futures=True) cancels the jobs that have not started. Jobs that are already running still run to the end.
  • shutdown(wait=False) returns right away, but the jobs keep running, and Python still waits for them before the program exits.

Neither option stops a running job. A stuck job holds up the shutdown, so give slow calls their own timeouts.

Going further

  • Python 3.14 added a buffersize argument to Executor.map. It limits how many jobs map queues ahead of the results you have read.
  • Since Python 3.13, the default max_workers comes from os.process_cpu_count(), which counts the CPUs your process may use. That number has nothing to do with the service your jobs call, so set max_workers yourself.

See the concurrent.futures documentation.

step 14 of 56

Concurrency with Threads and asyncio

Up next Write a Pool Map That Reports Every Failure