Structured Concurrency in Python
Structured concurrency in Python is the idea that every concurrent operation’s lifetime is bounded by a lexical scope, and that errors and cancellation flow through that scope the same way exceptions flow through a try block. Nataniel J. Smith coined the term in the Trio community, and the asyncio library adopted it in Python 3.11 with three pieces that are designed to compose: asyncio.TaskGroup, asyncio.timeout(), and ExceptionGroup (with the matching except* syntax).
Prerequisites
This is the capstone of the python-async series. You should already be comfortable with what an awaitable is, how asyncio.create_task works, and how a TaskGroup joins its children. If any of that is fuzzy, work through Async / Await Basics and Task Groups in Python first. The examples below use Python 3.11+ syntax for except* and asyncio.TaskGroup; the backport story is covered in the Pre-3.11 Alternatives section further down.
What structured concurrency means
It is easier to feel the principle by contrast. The “fire and forget” style looks like this: call asyncio.create_task(work()), drop the reference, and hope the task is still alive when you need its result. The event loop only holds a weak reference, so the task can be garbage-collected mid-execution. There is no parent, no scope, no obvious place for the error to land when something goes wrong.
Structured concurrency replaces that with five concrete rules.
- Lifetime nesting. Every concurrent operation’s lifetime is bounded by a lexical scope. You cannot leak a still-running task past the block that started it.
- Bounded fan-out. You always know how many tasks you spawned and which scope owns them. There is no “the event loop has 47 ghosts running somewhere.”
- Deterministic error propagation. If a child fails, the parent is informed immediately, siblings are cancelled, and the failure surfaces at the
async with. There are no silent task crashes that disappear intoPendingTaskwarnings. - Cancellation has a direction. Cancellation flows from outer scope in toward inner tasks. Children cannot cancel their parent by accident.
- The “nursery” mental model. Borrowed from Trio: a task group is a nursery. Tasks are checked in at the door; the door does not close until every child has left.
If you remember nothing else, remember rule 3 and rule 5. They are the reason the rest of the asyncio surface in this article exists.
The asyncio surface for it
The full structured-concurrency story in Python 3.11+ has three moving parts that compose into a single mechanism.
asyncio.TaskGroup is an async context manager. Inside the async with, you call tg.create_task(coro) to add children. When the block exits, every child is awaited. If any child raises, the group cancels its siblings, waits for cancellation to settle, then raises a single ExceptionGroup containing all the failures. KeyboardInterrupt and SystemExit are special-cased: they are not wrapped.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as r:
return await r.json()
async def main(urls):
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(session, u)) for u in urls]
# every task is done here; nothing can outlive this line
return [t.result() for t in tasks]
asyncio.timeout(delay) is the time-bound sibling of a task group. It is an async context manager that schedules cancellation of the current task after delay seconds, catches the resulting CancelledError, and re-raises it as TimeoutError at the boundary. The catch has to be outside the async with — inside the block, you only see CancelledError.
ExceptionGroup (and its parent BaseExceptionGroup) is the carrier for everything that goes wrong in a group of tasks. It is a subclass of BaseExceptionGroup and Exception, which is the key to the new except* syntax. except* ValueError matches a group only if it (or one of its subexceptions) is a ValueError; unmatched subexceptions are re-raised as a new group after the handler runs.
Composing the three pieces together
The pieces stop feeling like three separate APIs once you see them together. The pattern below is the keystone: an outer time budget, an inner fan-out, and two distinct error channels handled by two distinct mechanisms.
import asyncio
async def work_a(): ...
async def work_b(): ...
async def main():
try:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(work_a())
tg.create_task(work_b())
except TimeoutError:
# asyncio.timeout() raised this when the 5s budget passed
print("overall budget exceeded")
except* ValueError as eg:
# TaskGroup raised an ExceptionGroup[ValueError] if children failed
for e in eg.exceptions:
handle(e)
Notice that the two except clauses are doing different jobs. The first catches a deadline. The second catches a category of failure, and only that category — anything else inside the group re-raises after the handler. That is the whole point of except*: it lets you say “I know how to deal with this kind of failure, the rest is not my problem” without losing the rest.
When you need the same logic conditionally (or you are on 3.11 without wanting to write except* for some reason), ExceptionGroup.split and .subgroup are the older tools. split returns a (matched, rest) pair, both as groups; subgroup returns just the matched piece.
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(a())
tg.create_task(b())
except ExceptionGroup as eg:
fetch_eg, parse_eg = eg.split(lambda e: isinstance(e, FetchError))
for e in fetch_eg.exceptions:
log_fetch_failures(e)
if parse_eg is not None:
raise parse_eg
split is also what powers except* under the hood, so reading the two side by side is good for your mental model. A real-world example that ties this to network code is in Async HTTP with aiohttp.
Nested task groups and cancellation flow
Task groups nest. Each load_region below owns its own inner group, and the outer group owns one task per region. If a sibling inside the inner group fails, the inner group cancels its remaining tasks, then re-raises to the outer group, which cancels its remaining tasks. You get two cleanup waves, in order, not interleaved.
async def main():
async with asyncio.TaskGroup() as outer:
for region in regions:
outer.create_task(load_region(region))
async def load_region(region):
async with asyncio.TaskGroup() as inner:
for url in region.urls:
inner.create_task(fetch(url))
This is the property that makes nested groups feel safe: cancellation always has a clear “outermost still-running” scope, and you can rely on inner __aexit__ finishing before outer __aexit__ runs. The 3.13 release notes explicitly fixed a long-standing bug where the cancellation count could be lost between nested groups and external cancellation, so this composition is now exactly what it looks like.
Common Mistakes
A few things that bite people moving from asyncio.gather to TaskGroup, drawn from real code reviews rather than paranoia.
asyncio.create_task() outside a group is fragile. The loop holds a weak reference to the task; drop your reference and the task can be GC’d mid-execution. Inside a TaskGroup this is handled for you.
TimeoutError must be caught outside asyncio.timeout(). A try/except TimeoutError placed inside the async with will never fire, because the manager eats the CancelledError and substitutes TimeoutError only at the boundary.
except ValueError does not unpack an ExceptionGroup. This is the single most common error in the migration from gather. You need except* ValueError.
asyncio.gather is not wrong, it is a different shape. It returns the first exception, not a group. Use it when you genuinely want first-failure-wins behavior. Use TaskGroup when you want parallel-with-error-aggregation.
Only tg.create_task() is tracked by a task group. Calling asyncio.create_task() inside a async with TaskGroup() block creates an unowned task that the group will not await at exit. That is a frequent source of “my task did not run to completion” bugs.
asyncio.timeout() must run inside a running task. From a sync wrapper or a misconfigured asyncio.run(), you get RuntimeError: Timeout context manager should be used inside a task.
Pre-3.11 Alternatives
If you are stuck on 3.10 or earlier, the asyncio story is genuinely worse. TaskGroup, asyncio.timeout(), and except* are not available. The good news is the rest of the ecosystem has had this for years.
- Trio has nurseries — the original “structured concurrency” implementation. If you can choose your async library, Trio is the most consistent experience.
- AnyIO exposes a task-group API on top of both Trio and asyncio, so you can write structured code that works on either backend. This is the path of least resistance on older Python.
exceptiongroupon PyPI backportsExceptionGroup,BaseExceptionGroup,split, andsubgroupto 3.8–3.10. It does not backportexcept*(that is a syntax feature, not a runtime one), but thesplit/subgroupstyle works fine.
For a deeper comparison of Trio and asyncio as libraries, see Python Trio vs asyncio and the broader asyncio guide.
When to break out of the structure
Structured concurrency is the default shape, not a cage. There are times you genuinely want a task that outlives its spawning scope — a background logger, a websocket pump, a fire-and-forget email send. The rule is: you have to hold on to the task, because the loop only keeps a weak reference.
async def main():
bg: set[asyncio.Task] = set()
for msg in queue:
task = asyncio.create_task(send(msg))
bg.add(task)
task.add_done_callback(bg.discard)
# the set holds a strong ref so the loop does not GC the task
The docs themselves warn: “Save a reference to the result of this function, to avoid a task disappearing mid-execution.” Treat that as a contract. If you find yourself reaching for create_task outside a group often, the real question is usually “where should the scope for these tasks live?” — and the answer is almost always some task group, even if it is a long-lived one at the top of your program.
Conclusion
Structured concurrency in Python is one mechanism with three faces. TaskGroup gives you a nursery for children. asyncio.timeout() gives you a deadline that lives in the same lexical scope. ExceptionGroup plus except* lets you peel failures apart by category without losing the rest.
The rules to carry forward: hold tasks in a group, catch TimeoutError outside the timeout, catch groups with except*, and reach for fire-and-forget only when you have explicitly chosen to leave the structure. From here, the rest of the python-async series covers the patterns these primitives make possible.
Next steps
Structured concurrency shows up most often in I/O-heavy code. The async file I/O guide applies TaskGroup to chunked reads, and the aiohttp client guide builds a small parallel HTTP fetcher on top of the same primitives. For the lower-level surface, the asyncio module reference lists every method that TaskGroup ultimately delegates to. If you want the historical reference for the whole idea, the Python Trio vs asyncio guide walks through nurseries as Trio defined them.
See Also
- Async / Await Basics: series entry point
- Task Groups in Python: the previous article in this series
- Async HTTP with aiohttp:
aiohttpin aTaskGroup - Python Trio vs asyncio: Trio nurseries as the historical reference
- asyncio guide: broader event-loop reference