Skip to content
← All articles

asyncio vs anyio and trio

trio pioneered structured concurrency and asyncio adopted the shape. What still differs: the cancel scope as a first-class value, and level-triggered versus edge-triggered cancellation.

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 await inside a finally or an except CancelledError handler 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 Exception that a maintainer later widens to except BaseException without a raise silently 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.