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
Hide 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
buffersizeargument toExecutor.map. It limits how many jobsmapqueues ahead of the results you have read. - Since Python 3.13, the default
max_workerscomes fromos.process_cpu_count(), which counts the CPUs your process may use. That number has nothing to do with the service your jobs call, so setmax_workersyourself.
See the concurrent.futures documentation.