asyncio is Python’s library for running concurrent code with async and await. Calling a coroutine function defined with async def creates a coroutine object. Awaiting an unfinished operation lets the event loop run other tasks. A task wraps a coroutine so the event loop, the scheduler that runs all coroutines, can run it alongside other tasks. When several tasks run at once, you need a way to stop them: a deadline passed, a client disconnected, another task in the same group failed. asyncio stops tasks through cancellation, a cooperative mechanism: a cancellation request arranges for asyncio.CancelledError to be raised when the task next gets a chance to run. The task then unwinds, running finally blocks and the exit methods of async context managers (objects that run code when entering and leaving an async with block) as it goes.
asyncio.TaskGroup, added in Python 3.11, is the structured way to run a set of tasks. You create tasks inside an async with block, and the group waits for all of them when the block exits. If a task fails with an exception other than CancelledError, the group cancels the remaining tasks. It collects the failures and raises them together as an ExceptionGroup, a single exception that carries a list of other exceptions. That makes TaskGroup the right tool when a failure in one worker should stop the others and every worker should still get a chance to clean up.
You will see a complete runnable example, how cancellation reaches your code, how exception groups surface failures, how timeouts interact with the group, and where the model breaks down.
A Runnable TaskGroup Example with Cleanup
Here is a complete program. Each worker owns its connection and closes it in a finally block. Beta fails while alpha and gamma are waiting, so the group cancels those two workers.
import asyncio
class Connection:
"""A stand-in for a resource that needs an async close."""
def __init__(self, name: str) -> None:
self.name = name
async def close(self) -> None:
await asyncio.sleep(0)
print(f"{self.name}: connection closed")
async def worker(name: str, fail: bool = False) -> None:
connection = Connection(name)
try:
print(f"{name}: working")
if fail:
# Let gamma start before beta fails.
await asyncio.sleep(0)
raise ValueError(f"{name}: detected bad data")
# Stand in for unfinished work. This event is never set.
await asyncio.Event().wait()
except asyncio.CancelledError:
print(f"{name}: cancelled")
raise
finally:
# Each worker closes only the connection it owns.
await connection.close()
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(worker("alpha"))
tg.create_task(worker("beta", fail=True))
tg.create_task(worker("gamma"))
except* ValueError as eg:
print(f"value errors: {eg.exceptions}")
asyncio.run(main())
The program requires Python 3.11 or newer. TaskGroup, ExceptionGroup and except* arrived in 3.11. All three workers start. Beta closes its connection after failing. Alpha and gamma then report cancellation and close their own connections. Their order may vary. The caught value error is printed after cleanup finishes.
Alpha and gamma wait on events that are never set. Beta yields once with asyncio.sleep(0), then raises ValueError. After beta finishes its cleanup, the TaskGroup sees that failure and cancels the waiting workers. Their CancelledError handlers re-raise the cancellation, and their finally blocks close their connections. The group waits for both workers to finish before raising an ExceptionGroup containing beta’s error. The except* ValueError clause handles that error.
Two details matter here. First, the CancelledError raised inside alpha and gamma does not escape the async with block; the group treats that internal cancellation as expected and only the ValueError surfaces. Second, the finally block is the cleanup mechanism: it runs whether the task returns, raises, or is cancelled. If the resource exposes an async context manager, prefer async with over a manual finally; the __aexit__ method runs when control leaves the block, including when cancellation unwinds the coroutine.
One more propagation detail from the official documentation: if the async with body is still awaiting when a task fails, the task that contains the group is cancelled too. That CancelledError interrupts the body’s await but does not escape the async with statement; the group still raises the ExceptionGroup.
How Cancellation Reaches Your Code
asyncio.CancelledError subclasses BaseException, not Exception, so a plain except Exception will not catch it. Most code does not need to catch it at all; the official documentation recommends coroutines use try/finally for cleanup and generally propagate CancelledError if they do catch it.
Cancellation is cooperative. Calling Task.cancel() requests cancellation. It returns False if the task is already done. A True return means the request was accepted; it does not mean the task has stopped. Delivery happens when the task next resumes, and the coroutine can suppress the exception. A task can be cancelled only while it is pending or running. The cancellation can arrive from several places: another task calling cancel(), a timeout, a failure in another task in the same TaskGroup, or event loop shutdown. Passing a message to cancel() puts that text into the CancelledError for logging.
When the CancelledError is raised, the coroutine unwinds like any exception: finally blocks execute, and async context managers run their exit methods. That is why cleanup belongs in those constructs rather than in code after the await that gets cancelled.
Do not swallow CancelledError. TaskGroup and asyncio.timeout are implemented using cancellation internally, and the documentation warns they can misbehave if a coroutine swallows it. If you catch CancelledError to do cleanup, re-raise it when you are done. The docs say code which deliberately suppresses cancellation must also call uncancel() to remove its cancellation state. That does not make suppression safe for the surrounding application. User code should generally avoid uncancel(). Python 3.13 improved TaskGroup’s handling of simultaneous internal and external cancellations and preservation of cancellation counts.
ExceptionGroup and Failure Handling
When every task in a TaskGroup has finished, the group raises any failures that are not CancelledError as a single ExceptionGroup (or BaseExceptionGroup if a task raised a BaseException). The group is raised from the async with statement, so you handle it with except* clauses that match exception types inside the group, as the example above shows.
Two exceptions get special treatment. If any task raises KeyboardInterrupt or SystemExit, the group cancels the remaining tasks, waits, and re-raises the original exception directly instead of wrapping it in an ExceptionGroup. And if the async with body itself raises, that exception is folded into the group along with the task failures, unless it is CancelledError.
This failure behavior is the main reason to choose TaskGroup over asyncio.gather. If one submitted task raises under gather, the other tasks keep running; TaskGroup cancels them. The documentation describes TaskGroup as providing stronger safety guarantees for exactly this reason. The tradeoff is that sometimes you want all workers to finish even if one fails. A feature request proposes an abort_on_first_exception flag that would leave the other children running after a child failure, but the flag is not part of the documented API.
Timeouts and Cancellation
asyncio.timeout and asyncio.timeout_at cancel the current task when the deadline passes. The context manager turns its cancellation into TimeoutError outside the block. asyncio.wait_for requests cancellation of its awaitable on timeout and waits for cancellation to finish. If that awaitable suppresses cancellation and returns a value, wait_for can return the value. A timeout does not guarantee that work has stopped by the deadline.
A TimeoutError that leaves a worker is a task failure. This example lets it propagate. long_operation stands for an async operation supplied by your application:
async def worker(name: str) -> None:
async with asyncio.timeout(2):
await long_operation(name)
If the TimeoutError propagates out of the worker, the TaskGroup treats it as a task failure and cancels the other workers. That is often what you want for a batch of related operations. If you want one slow worker to time out without stopping the group, catch TimeoutError inside the worker so the task completes normally.
Cleanup Pitfalls and Cancellation Limits
Cleanup that awaits can be interrupted by another cancellation request. A positive Task.cancelling() count does not keep injecting CancelledError after that exception has been caught. CPython clears the delivery flag when it delivers cancellation. Keep cleanup in finally and let the original cancellation propagate. asyncio.shield protects the inner awaitable from its caller’s cancellation, but the caller still receives CancelledError. Shielding alone does not wait for cleanup to finish before the caller exits. If a cleanup task must outlive its caller, keep a strong reference and give another owner responsibility for awaiting it. Do not use uncancel() as a routine cleanup step.
Cancellation cannot interrupt synchronous code. A blocking function such as time.sleep or a CPU-bound loop blocks the entire event loop until it returns; cancelling the coroutine that called it does nothing until the function yields control. Similarly, cancelling a coroutine that awaits asyncio.to_thread, which runs a blocking function in a worker thread, does not reliably stop the underlying thread function. If your workers call blocking libraries, run them in a way that can be stopped, or accept that cancellation is delayed.
Cancellation does not undo external side effects. If a worker sent a payment request and the request succeeded before cancellation arrived, the money moved. The practical guidance is to design for this with idempotency keys, transaction boundaries, or reconciliation logic, because cancellation only stops the coroutine, not the remote system.
TaskGroup does not cancel tasks just because the async with block exits normally; the group only waits for them. If your workers are designed to run indefinitely, you need an explicit shutdown path. Python 3.15 adds TaskGroup.cancel(), which cancels any unfinished tasks and the parent body, exits the context manager without raising CancelledError, and is idempotent; it can be called from inside the body, passed to child tasks, or passed to another task for remote cancellation. Before 3.15, the common workaround was to inject an exception through create_task, but the injected task is scheduled, not run right away, so other tasks may wake and act first.
Avoid fire-and-forget tasks. A task created without a retained reference, an await, or a cancellation policy can be garbage collected mid-execution or leak resources, and if it fails with its exception never retrieved, asyncio logs a "Task exception was never retrieved" message. TaskGroup avoids this by keeping strong references to its tasks, awaiting them, and propagating their exceptions. There are also known edge cases in specific environments: a CPython bug report describes CancelledError leaking out of a TaskGroup on Python 3.12 when using asyncio.eager_task_factory, and a Nuitka report describes TaskGroup failing to guarantee completion in a compiled standalone binary.
Cancellation and Cleanup Takeaways
- Put cleanup in
try/finallyorasync with: both run when a task is cancelled, so the resource closes on return, failure, and cancellation paths. - Never swallow
CancelledError: TaskGroup andasyncio.timeoutdepend on internal cancellation; if you catch it, re-raise it after cleanup. - Handle failures as
ExceptionGroup: useexcept*to match exception types, and remember TaskGroup cancels the other tasks on the first failure, unlikeasyncio.gather. - Treat timeouts as cancellations with deadlines:
asyncio.timeoutcancels the task and then raisesTimeoutError; a timeout that escapes a worker stops the whole group. - Design for what cancellation cannot do: blocking synchronous code, thread functions, and completed remote side effects survive a cancellation request, so add idempotency and shutdown logic.