Fan out many awaits at once, collect the results, and decide what should happen when one of them fails.
Overview
The shape of the win
The reason to reach for async is almost always the same: you have many independent waits — requests, queries, file reads — and doing them one after another wastes the time each one spends idle. Running them together collapses the total time from the sum of the waits to the longest single wait.
asyncio.gather is the direct tool. Hand it several coroutines and it schedules them all as tasks, waits for every one, and returns their results in the order you passed them — not the order they finished.
These editors run inside a browser event loop that is already going, so the examples finish with await main(). In a standalone .py script — run with python file.py — you write asyncio.run(main()) instead, which starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.
Three waits of 0.3, 0.2 and 0.1 seconds finish in 0.30, not 0.60 — the total is the longest one, because all three timers ran at once. And the results came back in call order (A, B, C) even though C finished first: gather lines the results up with the arguments, so you can index them without tracking which finished when.
Running Work Concurrently: gather and TaskGroup
Fan out many awaits at once, collect the results, and decide what should happen when one of them fails.
What happens when one fails
By default, the first exception in any of the gathered coroutines stops the wait and propagates out of gather. The other coroutines are not cancelled, but their results are lost — you get the exception, not a partial list.
example_02.pyPython
import asyncio
async def square(n):
await asyncio.sleep(0.1); return n * n
async def boom():
await asyncio.sleep(0.05); raise ValueError("failed")
async def main():
try:
await asyncio.gather(square(2), boom(), square(3))
except ValueError as e:
print("gather re-raised the first exception:", e)
res = await asyncio.gather(square(2), boom(), square(3),
return_exceptions=True)
print("return_exceptions=True:", res)
await main()
Output
gather re-raised the first exception: failed
return_exceptions=True: [4, ValueError('failed'), 9]
The default hands you the first exception and hides the successful results. Passing return_exceptions=True changes the contract: nothing is raised, and the exception is placed in the results list where that coroutine's value would have been. That is the right choice when you want every result you can get and intend to inspect the failures yourself — and the wrong one if a failure should abort the whole batch, because it quietly turns errors into data you might forget to check.
TaskGroup: the modern default
gather has an awkward gap: when one coroutine fails, the others keep running unsupervised, and cleaning them up is your problem. Python 3.11 added asyncio.TaskGroup to close it, and it is now the recommended way to run concurrent work.
example_03.pyPython
import asyncio
async def work(n):
await asyncio.sleep(0.05 * n)
print(f"done {n}")
return n
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(work(i)) for i in range(1, 4)]
print("results:", [t.result() for t in tasks])
await main()
Output
done 1
done 2
done 3
results: [1, 2, 3]
The async with block does not exit until every task created inside it has finished, so the work is scoped: when control passes the closing line, all of it is done. This is structured concurrency — tasks live and die within a visible block rather than floating free — and it gives one guarantee gather does not: if any task raises, the group cancels all the others and then raises, so a failure never leaves siblings running in the background.
Failures come back as a group
Because several tasks can fail at once, a TaskGroup raises an ExceptionGroup, unpacked with the except* syntax also new in 3.11:
example_04.pyPython
import asyncio
async def ok(): await asyncio.sleep(0.1); print("ok ran")
async def bad(): await asyncio.sleep(0.02); raise ValueError("x")
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(ok())
tg.create_task(bad())
except* ValueError as eg:
print("TaskGroup raised ExceptionGroup:", [str(e) for e in eg.exceptions])
await main()
Output
TaskGroup raised ExceptionGroup: ['x']
When bad raises, the group cancels ok before it can finish — note that "ok ran" never prints — and surfaces the failure inside an ExceptionGroup. The except* form is how you handle it, and it is built for the real case the group creates: more than one task failing together.
Which to reach for
Want
Use
Results in call order, siblings independent
gather
Every result including failures, as data
gather(return_exceptions=True)
A failure to cancel the rest, cleanly scoped
TaskGroup
Results as they finish, not in order
as_completed
For new code on Python 3.11 or later, TaskGroup is the sensible default, and gather is for the cases where you specifically want the siblings to survive a failure.
Where it goes wrong
Gathering a huge list at once.gather over ten thousand coroutines starts ten thousand at once and can exhaust connections or memory. Cap the concurrency with a semaphore, or batch.
Ignoring return_exceptions results. With it on, a failure is a value in the list. Code that does not check for exceptions there treats an error as a success.
Expecting gather to cancel on failure. It does not; the other coroutines run on. TaskGroup is what cancels siblings.
Passing plain values to gather. Everything you gather must be awaitable. A non-coroutine in the list is an error, not a constant that passes through.
Recall check
0 of 3
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “The shape of the win”?
The reason to reach for async is almost always the same: you have many independent waits — requests, queries, file reads — and doing them one after another wastes the time each one spends idle. Running them together collapses the total time from the sum of the waits to the longest single wait.
What does this module say about “What happens when one fails”?
By default, the first exception in any of the gathered coroutines stops the wait and propagates out of gather . The other coroutines are not cancelled, but their results are lost — you get the exception, not a partial list.
What does this module say about “TaskGroup: the modern default”?
gather has an awkward gap: when one coroutine fails, the others keep running unsupervised, and cleaning them up is your problem. Python 3.11 added asyncio.TaskGroup to close it, and it is now the recommended way to run concurrent work.
Cheat sheet
Running Work Concurrently: gather and TaskGroup
The reason to reach for async is almost always the same: you have many independent waits — requests, queries, file reads — and doing them one after another wastes the time each one spends idle. Running them together collapses the total time from the sum of the waits to the longest single wait.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.