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, with a concurrency limit

This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.

Overview

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.

The shape of the win

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.

example_01.pyPython
Output
results: ['A:0.3', 'B:0.2', 'C:0.1']
elapsed 0.30s

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.

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
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
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
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

WantUse
Results in call order, siblings independentgather
Every result including failures, as datagather(return_exceptions=True)
A failure to cancel the rest, cleanly scopedTaskGroup
Results as they finish, not in orderas_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.

Results as they finish

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.

example_05.pyPython
Output
B after 0.1s
C after 0.2s
A after 0.3s

The 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.

First answer wins, cancel the rest

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.

example_06.pyPython
Output
first answer: fast
cancelled the rest: 1

This 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.

Limiting how much runs at once

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.

example_07.pyPython
Output
most in flight at once: 3
elapsed 0.17s for 9 waits of 0.05s, 3 at a time

Nine 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.

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.

Sources and next steps

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.

  1. What does this module say about “The shape of the win”?

  2. What does this module say about “What happens when one fails”?

  3. What does this module say about “TaskGroup: the modern default”?

Cheat sheet

Running Work Concurrently: gather and TaskGroup

Fan out many awaits at once with gather and TaskGroup, collect every result in order, and control what happens when one task fails.

ASYNC PYTHON · vizlearn.in/async_python/running_work_concurrently.html

About the author

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.