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.

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.

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.

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

  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

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.

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.