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.
Fan out many awaits at once, collect the results, and decide what should happen when one of them fails.
This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.
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.
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.
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. This starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.
results: ['A:0.3', 'B:0.2', 'C:0.1']
elapsed 0.30sThree 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.
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.
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.
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.
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.
Because several tasks can fail at once, a TaskGroup raises an ExceptionGroup, unpacked with the except* syntax also new in 3.11:
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.
| 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 in detail want the siblings to survive a failure.
gather lines results up with the arguments, which is convenient and means you wait for the slowest one before you see anything. When you want to act on each result the moment it arrives, asyncio.as_completed hands them over in completion order instead.
B after 0.1s
C after 0.2s
A after 0.3sThe same three coroutines as the first example, in the same order in the list, arriving back in the order they actually completed — B, C, A. The loop yields an awaitable each time one finishes, so the first result is available after 0.1 s rather than after 0.3.
That matters whenever partial results are useful: streaming rows to a client as each query returns, updating a progress bar, or stopping early once you have enough. The cost is that you lose the correspondence with your input list, so if you need to know *which* job a result came from, return it as part of the value — as the name above does — rather than relying on position.
gather waits for everything and as_completed hands you everything in turn. asyncio.wait is the third shape: it returns as soon as a condition is met and gives you two sets, done and pending, leaving what to do with the stragglers up to you.
first answer: fast
cancelled the rest: 1This is the hedged request pattern: ask two replicas the same question, take whichever answers first, and cancel the other so it stops consuming a connection. It cuts tail latency at the cost of extra load. And it is only expressible with wait, because gather insists on waiting for both and as_completed gives you no handle on the ones still running.
Two things about the API catch people. It takes tasks, not coroutines — passing coroutines was deprecated and is now an error, which is why the create_task calls are explicit here. And it returns sets, so there is no ordering to rely on; with more than one task in done you inspect them rather than indexing. The other return_when values are FIRST_EXCEPTION and the default ALL_COMPLETED, which makes wait the general form the other two are conveniences over.
gather over ten coroutines is fine. gather over ten thousand starts ten thousand at once, and whatever they talk to now has ten thousand simultaneous callers — which is how an async rewrite accidentally becomes a denial-of-service attack on your own database. Async removed the thread-count ceiling that used to limit concurrency by accident, so the limit now has to be deliberate.
An asyncio.Semaphore is the standard bound. It holds N permits; async with sem takes one and waits if none are free.
most in flight at once: 3
elapsed 0.17s for 9 waits of 0.05s, 3 at a timeNine coroutines were scheduled at once and never more than three ran at a time — the live list is the evidence, and its peak is exactly the semaphore's count. The elapsed time follows: three rounds of 0.05 s, plus scheduling overhead, rather than the 0.05 s an unbounded gather would have taken or the 0.45 s a sequential loop would.
The pattern to remember is that the semaphore goes *inside* the coroutine, around the part being limited, not around the gather. All nine tasks still exist and all nine are still scheduled; the bound is on how many are past the async with at once. If the work arrives as an open-ended stream rather than a known list, a bounded queue with a fixed number of workers is the better shape — same idea, different plumbing.
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.
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”?
What does this module say about “What happens when one fails”?
What does this module say about “TaskGroup: the modern default”?
Fan out many awaits at once with gather and TaskGroup, collect every result in order, and control what happens when one task fails.