Python Module 15 – Async programming with asyncio
TaskGroup and structured concurrency
Run related asyncio tasks in a TaskGroup so that none outlives its block: collect results, see one failure cancel the rest, and compare it with gather.
What you will learn
- Run related tasks inside an asyncio.TaskGroup and read their results
- Handle the failures a task group reports as an ExceptionGroup with except*
- Explain how one failing task cancels the rest of its group
- Choose between gather and a task group for a set of calls
- Apply limits, retries and early exits to the tasks of a group
Before you start
What you’ll learn · Before you start
What you will learn
- Run related tasks inside an asyncio.TaskGroup and read their results
- Handle the failures a task group reports as an ExceptionGroup with except*
- Explain how one failing task cancels the rest of its group
- Choose between gather and a task group for a set of calls
- Apply limits, retries and early exits to the tasks of a group
Before you start
On this page
Three price lookups of 0.2, 0.1 and 0.3 seconds finish in about 0.3 seconds in an asyncio.TaskGroup, and the block
that runs them does not end until all three are done. A TaskGroup runs a set of related tasks as one unit: you open it
with async with asyncio.TaskGroup() as tg: and start tasks with tg.create_task(). If one of them fails, the
TaskGroup cancels the others, waits for them to stop and raises an ExceptionGroup, an
exception group: An exception that carries several others, made with ExceptionGroup(message, exceptions). except* handles each kind of exception inside it, and a group can hold other groups. More in “Exception groups and except*” that holds the failures, which you handle with except*.
This is structured concurrency: Writing concurrent code so that every task belongs to a block that cannot end before the task does, and so no task outlives the code that started it. In asyncio, the async with block of a TaskGroup gives that guarantee.: no task outlives the block that started it,
just as no local variable outlives its function. The indentation shows how long the concurrent work lasts, and the
forgotten, half-finished tasks that create_task() and gather() can leave behind cannot happen to the tasks a group
starts. TaskGroup arrived in Python 3.11, and the asyncio documentation describes it as giving stronger safety
guarantees than gather.
A task group’s block waits for every task it started
Three shops answer a price lookup at different speeds:
"""Three price lookups in a TaskGroup: the block ends only when every task in it has finished."""
import asyncio
import time
SHOPS = {"shop-a": (0.20, 499), "shop-b": (0.10, 479), "shop-c": (0.30, 489)} # reply time in s, price in rupees
async def price(shop):
seconds, rupees = SHOPS[shop]
await asyncio.sleep(seconds) # stands in for an HTTP request
return rupees
async def main():
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = {shop: tg.create_task(price(shop), name=shop) for shop in SHOPS}
print("inside the block: tasks created, none finished:", not any(t.done() for t in tasks.values()))
print("after the block, every task is done:", all(t.done() for t in tasks.values()))
prices = {shop: task.result() for shop, task in tasks.items()}
best = min(prices, key=prices.get)
print(f"prices: {prices}; lowest at {best}")
print(f"took about {time.perf_counter() - start:.1f} s, the time of the slowest shop")
asyncio.run(main()) Output
inside the block: tasks created, none finished: True
after the block, every task is done: True
prices: {'shop-a': 499, 'shop-b': 479, 'shop-c': 489}; lowest at shop-b
took about 0.3 s, the time of the slowest shop
This output changes from run to run: the last line measures time, which varies a little from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 price_lookups.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
tg.create_task() works like asyncio.create_task(), with one difference: the task belongs to the group. Inside the
block the tasks were only created. Leaving the block waited for all three, so afterwards every task was done and
task.result() gave its value straight away. Step through the run on a clock:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three tasks created. Inside the block, tg.create_task() creates one lookup per shop. They belong to the group and have not run yet, so the body prints that none has finished.
- The body ends. The body has nothing more to do, but leaving the block means waiting for every task of the group. The tasks start while it waits.
- 0.1 s, shop-b. shop-b answers first, with 479 rupees. The block still waits for the other two.
- 0.2 s, shop-a. shop-a answers with 499 rupees.
- 0.3 s, shop-c. shop-c answers last, with 489 rupees. Every task of the group is done, so the block ends.
- After the block. Every task is done, so task.result() gives each price at once. The lowest is at shop-b, and the whole lookup took about 0.3 s, the time of the slowest shop.
The three waits overlapped, and the lookup took as long as the slowest shop. Keeping the tasks in a dictionary keyed by shop is the usual way to read the results; a TaskGroup has no list of results of its own. Press Edit, give shop-c 0.05 seconds instead of 0.30 and run it again: the block now ends with shop-a, after about 0.2 seconds.
Checkpoint
Check: A group's block waits for its tasks
One failure cancels the rest and raises an ExceptionGroup
Now shop-b does not list the product, and its lookup raises:
"""One task fails: the group cancels the others and raises an ExceptionGroup, handled here with except*."""
import asyncio
log = []
async def price(shop, seconds, fail=False):
try:
await asyncio.sleep(seconds)
if fail:
raise LookupError(f"{shop}: product not listed")
log.append(f"{shop} answered")
return 499
except asyncio.CancelledError:
log.append(f"{shop} cancelled")
raise # a cancelled task must let the cancellation through
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(price("shop-a", 0.05))
tg.create_task(price("shop-b", 0.10, fail=True))
tg.create_task(price("shop-c", 0.30))
print("this line is skipped")
except* LookupError as group:
print("caught:", type(group).__name__, "-", group.message)
print("inside it:", [str(e) for e in group.exceptions])
print("what happened:", log)
asyncio.run(main()) Output
caught: ExceptionGroup - unhandled errors in a TaskGroup inside it: ['shop-b: product not listed'] what happened: ['shop-a answered', 'shop-c cancelled']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_fails.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
shop-a answered first. When shop-b raised its LookupError, the group cancelled shop-c, which was still waiting,
waited until it had stopped and then raised an ExceptionGroup with the message “unhandled errors in a TaskGroup”.
Step through what the group did, one move at a time:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Three lookups start. The group runs three price lookups. shop-b's will raise LookupError, because the product is not listed there.
- 0.05 s, shop-a answers. shop-a answers before anything goes wrong, and the log records shop-a answered.
- 0.1 s, shop-b raises. shop-b raises LookupError. The exception does not leave the task directly; the group receives it.
- The group cancels shop-c. The first failure makes the group cancel every task that is not done. shop-c is still waiting, so it gets CancelledError at its await.
- shop-c lets it through. shop-c records shop-c cancelled and raises the CancelledError again, as a cancelled task must. The group waits until it has stopped.
- An ExceptionGroup comes out. With every task stopped, the group raises ExceptionGroup("unhandled errors in a TaskGroup") holding the LookupError. It leaves the block, so the line after the block never runs.
- except* matches inside it. except* LookupError matches the error inside the group and prints it. A plain except LookupError would not match, because the group itself is not a LookupError.
The exception left the async with block, so the line after the block never ran. except* LookupError matched the
error inside the group; a plain except LookupError would not have matched, because the group is not a LookupError,
as the lesson on exception groups showed. Every task that raised before the cancellation took effect is in the group,
so two shops that failed together would both be reported.
Checkpoint
Check: One failure cancels the rest
gather or TaskGroup: choose by what one failure should do
The two differ most when a call fails, which the first rows of the table set side by side:
| Criterion | gather() | TaskGroup |
|---|---|---|
| One call fails | the first error is raised; the other calls keep running | the other tasks are cancelled, then an exception group is raised |
| Two fail at once | only the first is raised | both are in the group |
| Results | a list in argument order | task.result() of each task |
| Afterwards | calls may still be running | none: the block waits for all of them |
| New work | no: the calls are fixed | yes: tasks can add tasks to the group |
| Every outcome | return_exceptions=True | each task catches its own expected errors |
| When to choose | Independent calls where you want every outcome | Calls that must all succeed, and any new code |
Use a TaskGroup for work that only makes sense as a whole, such as the parts of one page or the steps of one order.
Use gather with return_exceptions=True when each call stands on its own and you want every outcome, good or bad.
The table’s last row joins the two: a task that catches its own expected errors turns a group into a batch with
partial results.
Start from a task group
In new code, start from a TaskGroup and step down to gather only when you want every outcome: the group’s way of failing is the safer default.
Checkpoint
Check: Choosing gather or a task group
The one rule: every task goes through the group
The guarantee covers only the tasks that the group created. One asyncio.create_task() in the block breaks it:
"""The one rule: every task of a group is made with tg.create_task(). A task made with asyncio.create_task() inside
the block is not part of the group, so the block neither waits for it nor cancels it."""
import asyncio
log = []
async def job(name, seconds):
await asyncio.sleep(seconds)
log.append(f"{name} finished")
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(job("owned", 0.05))
stray = asyncio.create_task(job("stray", 0.20)) # the mistake: not tg.create_task()
print("block exited:", log, "| stray still running:", not stray.done())
await stray # without this line, nothing would ever wait for it
print("much later:", log)
asyncio.run(main()) Output
block exited: ['owned finished'] | stray still running: True much later: ['owned finished', 'stray finished']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stray_task.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The block ended after 0.05 seconds, when the owned task was done, while the stray task was still running. Nothing in the group waited for it, and nothing would have cancelled it if another task had failed. Step through the two tasks:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Two tasks, one group. Inside the block, owned is made with tg.create_task(), so the group knows it. stray is made with asyncio.create_task(), so the group knows nothing about it.
- 0.05 s, the block ends. owned finishes at 0.05 s. It is the only task the group waits for, so the block ends while stray is still running.
- stray is on its own. main() prints block exited with only owned finished, and stray still running True. Had another task failed, the group would not have cancelled stray either.
- 0.2 s, stray ends. stray finishes only because main() awaits it after the block. Without that line nothing would ever wait for it, or see its errors.
asyncio.create_task() inside a task group's block
asyncio.create_task(audit(order)) inside the block makes a task the group knows nothing about: the block may end
while it runs, and a failure will not cancel it. Write tg.create_task(audit(order)), and pass tg to any
coroutine that needs to start more work in the same group.
Once the block has ended, the group takes no new tasks: tg.create_task() then raises RuntimeError, and since
Python 3.13 it also closes the coroutine it was given, so no “never awaited” warning follows the error.
Checkpoint
Check: Every task goes through the group
Five patterns that combine with a task group
Five patterns combine with a task group, each answering one question a real service asks:
| Pattern | What it adds to a group |
|---|---|
| A semaphore: A counter of permits, such as asyncio.Semaphore(20), that lets at most that many coroutines through at a time while the others wait for a permit, which caps how many calls run at the same moment. More in “Tasks, gather and running coroutines” | Caps how many of the group’s tasks run their calls at the same moment |
| Partial results | A task that catches its own expected errors gives an answer for its item without hiding real bugs |
| Retries with exponential backoff: Waiting longer before each new attempt, doubling the wait each time up to a cap, so that a failing service gets time to recover instead of a steady stream of retries. More in “Designing error handling for real programs” | A flaky call tries again, waiting a little longer each time, before it fails the group |
| An early exit | Stops the whole group once one task has the answer |
asyncio.timeout() |
Puts one time limit around the whole group |
Three of them live inside the tasks; the timeout and the early exit act on the whole block.
Checkpoint
Check: Patterns that combine with a group
Check yourself
Practice on the five sections above follows, a question at a time, the idea with the fewest right answers first and a reason given for each choice.
Practice that adapts to you
Practise this lesson
10 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Failures and cancellation in depth, with two bugs reproduced Pro: opens with a Pro, Premium or Ultimate pass
A failing task sets off a fixed sequence inside its group, and two common bugs hide that sequence from the people who debug it. Below, the sequence is traced with timestamps, a whole group gets one time limit, and both bugs are reproduced.
In this part:
- The cancellation sequence, traced with timestamps
- A time limit around the whole group
- Bug 1 reproduced: a one-line log that hides every error
- Bug 2 reproduced: a stray task that writes after the request ended
1 diagram · 4 runnable examples (Python) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Patterns for real services: limits, partial results, retries and early exits Pro: opens with a Pro, Premium or Ultimate pass
Real services need more from a task group than running calls together: a cap on calls in flight, the results of the items that worked, retries for flaky calls and a way to stop as soon as the answer is known. Each pattern below runs inside a task group, and the exercise at the end combines two of them.
In this part:
- Capping a group at 5 calls at a time, with the estimate
- Partial results without hiding bugs
- Retries with exponential backoff and jitter, with the call counts
- Stopping early once the answer is known
- Exercise: the first source that answers wins
- The first source that answers wins, the rest are cancelled
- Results of the sample tests
4 runnable examples (Python) · 1 exercise with sample tests · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Mid to senior
Your service uses
gather()for three calls. When one fails, the request returns an error, but the other two calls keep running. What changes if you switch to anasyncio.TaskGroup, and what can still go wrong? -
Mid to staff
A nightly job fetches 10,000 pages inside one TaskGroup. After 20 minutes, one page triggers a parsing bug, and the whole night's work is lost. Why, and how would you structure the job instead?
-
Fresher to senior
Your logs show only "unhandled errors in a TaskGroup (3 sub-exceptions)" for failed checkouts. How do you see what actually failed, and what do you change so that the next failure is easy to diagnose?
Key takeaways
async with asyncio.TaskGroup() as tg:runs every task made withtg.create_task(), and the block waits for all of them.- When one task fails, the group cancels the others, waits for them and raises an
ExceptionGroupwith every failure; handle it withexcept*. - Structured concurrency means no task outlives the block that started it.
- Prefer a TaskGroup for calls that must all succeed, and gather with
return_exceptions=Truefor independent calls whose every outcome you want. - Create every task of the block through the group: a task from
asyncio.create_task()escapes it.
Terms in this lesson
- structured concurrency
- Writing concurrent code so that every task belongs to a block that cannot end before the task does, and so no task outlives the code that started it. In asyncio, the async with block of a TaskGroup gives that guarantee.
References
- Coroutines and Tasks: task groups (Python Software Foundation)
- Coroutines and Tasks: asyncio.gather and its note on TaskGroup (Python Software Foundation)
- Coroutines and Tasks: timeouts (Python Software Foundation)
- Coroutines and Tasks: task cancellation (Python Software Foundation)
- Synchronization Primitives: Semaphore (Python Software Foundation)
- Built-in Exceptions: exception groups (Python Software Foundation)
- The Python Language Reference: except* clause (Python Software Foundation)
- traceback.format_exception() (Python Software Foundation)
- logging: Formatter.formatException() (Python Software Foundation)
- PEP 654: Exception Groups and except* (Python Software Foundation)
- Coroutines and Tasks (Python 3.15): TaskGroup.cancel() (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress