This is a once-per-codebase decision, and it is expensive to reverse — not because of syntax, but because the cancellation model leaks into the shape of every function you write. Teams that pick without understanding that ship services which hang on shutdown.
What trio contributed
trio invented structured concurrency for Python, and asyncio adopted the shape in 3.11. The core claim is simple: a concurrent task’s lifetime is bound to a lexical block. Nothing you start inside the block escapes it.
trio calls the block a nursery; asyncio calls it a TaskGroup. The
semantics are near-identical, and if you have understood TaskGroup you have
understood nurseries. anyio sits on top of either backend and gives you trio’s
API shape while running on asyncio underneath — which is why libraries
targeting both (httpx, for instance) build on anyio.
What still differs: the cancel scope
This is the substantive gap, and it is not syntactic.
In trio and anyio, a cancel scope is a first-class object:
with anyio.CancelScope() as scope:
...
scope.cancel() # cancels this scope, from anywhere
You can create one, pass it around, store it on an object, hand it to another task, cancel it from outside, nest them arbitrarily, and give each its own deadline. A “cancel everything this subsystem is doing” handle is a value with a type.
In asyncio, the cancel scope is implicit in the async with block. There
is no object. You cannot name it, store it, or pass it to anything — which
means “the thing that cancels this work” is not expressible in a signature.
The workaround is to pass the Task around and call .cancel() on it, which
is not the same: a Task is one unit of work, a cancel scope is a region.
Python 3.15 adds TaskGroup.cancel(), which closes part of the gap — you can
finally exit a task group early without exceptions, which previously required
raising a sentinel exception and catching it outside the block. It still does
not give you a reusable standalone scope.
💡Why does having the cancel scope as a value matter in practice? Give a concrete case where the asyncio shape is awkward. click to reveal
The clean case is a subsystem with a lifetime that is not a lexical block — a websocket connection, a subscription, a background sync for one tenant.
With trio/anyio you hold the scope: self._scope = CancelScope(), run the work
inside it, and self.stop() is one line — self._scope.cancel(). Anyone with a
reference to the object can stop the region, and everything inside it unwinds
with finally blocks running.
With asyncio you cannot store the scope, so you store the Task instead and
call task.cancel(). That works for a single task. The moment the subsystem
runs several tasks, you are hand-rolling a registry of them plus the logic to
cancel them all and wait for them — which is a TaskGroup, except you have to
keep the group’s async with alive in some task somewhere, and that task now
needs its own way to be told to exit. The usual result is a sentinel exception
raised into the group’s body, which is exactly the hack 3.15’s
TaskGroup.cancel() removes.
None of this is impossible in asyncio. It is a couple of dozen lines you write once and get subtly wrong twice — and that is a fair summary of the whole gap.
The one that actually causes bugs: edge vs level
trio’s cancellation is level-triggered. Once a cancel scope is cancelled,
every checkpoint inside it raises Cancelled — again, and again, and again —
until control leaves the scope. You cannot accidentally escape it by catching
the exception once, because the next await raises it again.
asyncio’s is edge-triggered. Task.cancel() delivers exactly one
CancelledError. If your code catches it and continues, subsequent awaits
proceed normally, and the task is no longer being cancelled.
Both designs are defensible, and each buys something real:
-
asyncio’s edge model is what makes an
awaitinside afinallyor anexcept CancelledErrorhandler work naturally — which is the whole basis of the bounded-cleanup pattern in item 9.10. In trio that same cleanup needs an explicit shielded scope. -
trio’s level model makes accidental escape impossible. In asyncio, one
except Exceptionthat a maintainer later widens toexcept BaseExceptionwithout araisesilently converts a cancellable task into an uncancellable one.
The practical rule for asyncio: every except BaseException must end in
raise, and any except CancelledError must either re-raise or be a
deliberate, commented, bounded decision.
Choosing
| You want | Pick |
|---|---|
| The stdlib, the largest ecosystem, no extra dependency | asyncio |
| Rigorous cancellation you cannot accidentally escape; a research-grade model | trio |
| To write a library that works under either | anyio |
If you are writing an application on top of an ecosystem that is already asyncio-native — FastAPI, aiohttp, asyncpg, aiokafka — asyncio is the answer and the interesting question is only whether you understand cancellation well enough to use it correctly. That is what this track is for.
What is not defensible is mixing paradigms. Running trio primitives on an asyncio loop, or awaiting an asyncio Future from inside a trio nursery, gives you two cancellation models operating on the same call stack. Use anyio’s compatibility layer if you must bridge; do not improvise it.