Comparing Trio Nurseries and asyncio TaskGroup¶
asyncio.TaskGroup, added in Python 3.11, is trio's nursery with the serial numbers filed off — deliberately, and to everyone's benefit. The core guarantees are identical, and the verification below shows it: on both runtimes, a failing child cancelled both siblings and raised an ExceptionGroup containing the ValueError, with identical sibling logs. What remains different is a short list: trio has nursery.start() for children that must signal readiness, a cancel scope attached to the nursery itself, and — most importantly — no create_task escape hatch, so the structure is a guarantee rather than a convention.
Prerequisites¶
- Python 3.11+ for
asyncio.TaskGroup;trio(pip install trio) for the comparison. - Group semantics from Exception Groups & TaskGroups.
- Cancel scopes from using AnyIO cancel scopes.
1. See what the two share¶
Both are a block that waits for its children and cancels them if one fails:
# trio
async with trio.open_nursery() as nursery:
nursery.start_soon(worker, arg) # function and arguments
# asyncio
async with asyncio.TaskGroup() as tg:
tg.create_task(worker(arg)) # a coroutine object
Three children sleeping 50 ms completed in 0.05 s with the nursery waiting for all of them. With a failing child:
[trio] failing child -> ExceptionGroup['ValueError']; siblings ['slow1 cancelled', 'slow2 cancelled']
[asyncio] failing child -> ExceptionGroup['ValueError']; siblings ['slow1 cancelled', 'slow2 cancelled']
Byte-for-byte the same behaviour. If you have internalised one model you have internalised the other, and the remaining differences are about what else the API offers.
The small spelling difference is worth noting: start_soon(fn, *args) takes the function and its arguments, which makes it impossible to create a coroutine and forget to await it. create_task(worker(arg)) takes an already-created coroutine object — and a typo that omits create_task leaves a never-awaited coroutine and a warning at some later point.
Verify: the same structured test passes against both runtimes.
2. Use nursery.start() when a child must signal readiness¶
This is the feature with no asyncio equivalent, and it solves a real problem: starting a server child and knowing when it is actually listening.
async def server(task_status=trio.TASK_STATUS_IGNORED):
listener = await bind_socket()
task_status.started(listener.address) # ready, and here is the address
await serve_forever(listener)
async with trio.open_nursery() as nursery:
address = await nursery.start(server) # returns only when the child is ready
await connect_to(address)
Verified: start() returned ('127.0.0.1', 9999) after 0.05 s, once the child had signalled. The caller gets both a happens-before guarantee and a value, and a child that fails during initialisation propagates that failure to the caller of start() rather than as a mysterious group error later.
In asyncio you build it yourself:
ready = asyncio.Event()
result: dict = {}
tg.create_task(server(ready, result))
await ready.wait() # and handle the failure case manually
which works, and is four lines and an error path that start() gets right for you.
Verify: nothing connects to a service child before it has signalled readiness.
3. Cancel the group, from anywhere¶
A trio nursery exposes its own cancel scope:
async with trio.open_nursery() as nursery:
nursery.start_soon(worker, 1)
nursery.start_soon(worker, 2)
await shutdown_event.wait()
nursery.cancel_scope.cancel() # stops every child, cleanly
Verified: cancelling the nursery from inside stopped both children at 0.05 s, each recording its own cancellation, and the block exited without raising.
asyncio has no equivalent, which is why cancelling a TaskGroup from inside a child needs a sentinel exception, a shared flag, or holding task handles and cancelling them individually. All three work; none is as direct as an object with a cancel() method.
The practical consequence shows up in servers. "Run these workers until told to stop" is a nursery plus a cancel scope in trio, and a small protocol in asyncio.
Verify: the group's shutdown path is one call, or an equivalent you have tested.
4. Weigh the escape hatch¶
The deepest difference is not an API at all. In asyncio, asyncio.create_task() still exists, so a task can be created outside any group and live for the process's lifetime — which is how background task leaks happen. Structure in asyncio is a convention the team maintains.
In trio there is no such function. Every task belongs to a nursery, every nursery is a block, and the block cannot exit while a child is running. Structured concurrency is a property of the runtime rather than a style guide, and the entire class of "who owns this task" bugs is eliminated by construction.
The same asymmetry appears in cancellation. Trio's Cancelled is documented as something you must never swallow, and the runtime checks that it propagates; asyncio's CancelledError can be caught and ignored, which silently converts a cancelled task into a completed one — a bug you have to write a test to catch.
Verify: in asyncio, create_task outside a group is a reviewed exception rather than a habit.
5. Choose by constraint, not by preference¶
- An application on asyncio libraries.
asyncio.TaskGroup. The core guarantees are the same, the ecosystem is there, and adding a layer buys little. - A library used by both communities. AnyIO task groups, which give the trio model on either runtime — see writing backend-agnostic code with AnyIO.
- A greenfield application that values the guarantees over the ecosystem. Trio, accepting that
asyncpg,aiokafkaand most drivers will not be available withouttrio-asyncio.
The honest framing is that the argument was settled by convergence. Trio demonstrated the model; asyncio adopted it; the remaining differences are two convenience features and one philosophical stance about escape hatches. For most teams the right answer is TaskGroup plus the discipline trio enforces automatically.
Verify: the choice is written down with its reason, so the next person does not relitigate it.
Verification¶
The model is applied correctly when:
- Every task belongs to a group — enforced in trio, reviewed in asyncio.
- Failures cancel siblings, verified by a test.
- Readiness is explicit:
nursery.start()or anEventyou await. - Group shutdown is one operation, however it is implemented.
CancelledErroris never swallowed, with a test that proves it.- The runtime choice has a stated reason.
Pitfalls & edge cases¶
nursery.start_soon(fn(arg)). Passing a coroutine object instead of the function and arguments raises.- Assuming
TaskGroupcancels on areturn. Leaving the block normally waits for children; only an exception cancels them. create_taskoutside a group. Works, and reintroduces every ownership problem structured concurrency removes.- Expecting trio libraries to work on asyncio. They do not without AnyIO or
trio-asyncio. - Catching
BaseExceptionin a child. It swallowsCancelled/CancelledErrorand breaks the group's contract. - Nurseries held across function boundaries. Passing a nursery around recreates unstructured concurrency with extra steps.
Frequently Asked Questions¶
Is asyncio.TaskGroup the same as a trio nursery?
In its core behaviour, yes: the block waits for every child, a failing child cancels its siblings, and the failures arrive as an ExceptionGroup. Verified on both runtimes with identical results. Trio adds nursery.start() for readiness and a cancel scope on the nursery, and has no create_task escape hatch.
What does nursery.start() do that create_task cannot?
It waits until the child signals readiness with task_status.started(value) and returns that value to the caller — measured, a bound address returned after 0.05 s. Initialisation failures propagate to the caller of start(). In asyncio you build the same thing from an Event plus your own error handling.
How do I cancel all tasks in a group?
In trio, nursery.cancel_scope.cancel() — verified, both children stopped immediately and the block exited cleanly. In asyncio there is no equivalent: raise a sentinel exception from a child, set a shared flag, or keep task handles and cancel them individually.
Should I use trio instead of asyncio?
Only if the guarantees matter more than the ecosystem. Trio has no create_task escape hatch, so structured concurrency is enforced rather than conventional — but most database, broker and RPC drivers are asyncio-only. For libraries, AnyIO gives the trio model on both runtimes.
Does TaskGroup prevent task leaks?
Within the block, yes: nothing can outlive the async with. It does not remove asyncio.create_task, so a task created outside any group still leaks exactly as before. That is the one structural difference from trio, where no such function exists.
Related¶
- AnyIO & Trio Interop — up to the topic overview.
- Migrating from gather to TaskGroup — adopting the model in asyncio.
- Cancelling a TaskGroup from inside a child — what trio does with one method call.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.