Python's asyncio makes it possible to run many asynchronous operations without creating a separate thread for each one. It is useful for HTTP requests, database calls, background workers, message processing, and other I/O-heavy workloads.
However, starting an asynchronous task is only part of the job. Applications must also handle what happens when a task is cancelled.
Cancellation can occur when a user closes a request, an application shuts down, a timeout expires, or another task fails. If the cancelled task does not clean up its resources properly, it can leave database connections open, HTTP sessions running, background tasks waiting indefinitely, or partially completed operations behind.
The important point is that asyncio cancellation is cooperative. Python requests that a task stop; it does not forcibly terminate the task at an arbitrary instruction.
This article explains how cancellation works, how to clean up resources safely, and how to avoid common mistakes in production applications.
How Cancellation Works in Python asyncio
An asyncio.Task represents a coroutine scheduled to run on the event loop. Calling cancel() requests that the task be cancelled.
import asyncio
async def worker():
while True:
print("Worker is running")
await asyncio.sleep(1)
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(3)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Worker was cancelled")
asyncio.run(main())When task.cancel() is called, the task is marked for cancellation. At a suitable suspension point, typically an await, asyncio raises asyncio.CancelledError inside the coroutine.
The coroutine can then stop and perform cleanup. If the cancellation exception propagates out of the coroutine, the task finishes in the cancelled state.
Calling cancel() does not mean the coroutine has already stopped. The task may need time to reach an await point, execute cleanup code, or finish handling the exception.
This distinction matters when shutting down services. A cancellation request should normally be followed by awaiting the task so that the application knows when its cleanup has finished.
Use try/finally to Release Resources
The most reliable way to perform cleanup during cancellation is to use a finally block.
Python executes the finally block when a coroutine exits normally, raises an exception, or receives a cancellation exception that unwinds the coroutine.
import asyncio
async def background_worker():
print("Worker started")
try:
while True:
await asyncio.sleep(1)
print("Processing work")
finally:
print("Releasing worker resources")
async def main():
task = asyncio.create_task(background_worker())
await asyncio.sleep(3)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Cancellation completed")
asyncio.run(main())When the task is cancelled, the finally block runs before the coroutine finishes. The caller then receives CancelledError when it awaits the cancelled task.
In a real application, the cleanup might release a lock, stop a worker, close a connection, or remove a temporary file.
The cleanup code should be as reliable and short as possible. If it performs asynchronous operations, those operations can themselves be interrupted by another cancellation request. For critical asynchronous cleanup, use the appropriate resource-management API and a clearly defined shutdown strategy rather than assuming that every cleanup operation will always finish.
Prefer async with for Resource Management
When a library provides an asynchronous context manager, use it instead of managing the resource manually.
An asynchronous context manager implements __aenter__() and __aexit__(). The async with statement ensures that the exit method is called when control leaves the block, including when cancellation unwinds the coroutine.
For example, an application using an asynchronous HTTP client might look like this:
import asyncio
import aiohttp
async def fetch_data(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
response.raise_for_status()
return await response.json()
async def main():
try:
data = await fetch_data(
"https://example.com/api/data"
)
print(data)
except asyncio.CancelledError:
print("Request was cancelled")
raise
except aiohttp.ClientError as exc:
print(f"HTTP request failed: {exc}")
asyncio.run(main())The session and response are managed by their respective context managers. If cancellation occurs while the request is running, the coroutine exits through the context managers and their cleanup methods are invoked.
The example re-raises CancelledError because cancellation is not an ordinary HTTP failure. The application may need to know that the request was cancelled so it can stop related work or complete its own shutdown.
The same principle applies to asynchronous database connections, transactions, file operations, and other resources that support asynchronous context management.
Understand CancelledError and Exception Handling
One common mistake is to catch every exception and treat cancellation as a normal error.
In modern Python, asyncio.CancelledError inherits from BaseException, not Exception. Therefore, a standard except Exception handler does not normally catch it.
Consider this example:
import asyncio
async def process_job():
try:
await asyncio.sleep(10)
except Exception as exc:
print(f"Job failed: {exc}")If this task is cancelled while waiting, the except Exception block will not normally catch the cancellation. The cancellation propagates through the coroutine.
That behavior is generally desirable. It prevents ordinary error-handling code from accidentally converting cancellation into success.
However, code that explicitly catches BaseException, or code that catches CancelledError directly, can interfere with cancellation if it does not re-raise the exception.
Use this pattern when cancellation-specific handling is necessary:
import asyncio
async def process_job():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("Stopping job processing")
raise
except Exception as exc:
print(f"Job failed: {exc}")The raise statement preserves cancellation semantics. Without it, the coroutine may continue running or return normally, even though its caller requested cancellation.
Only suppress cancellation when the application has a specific reason to do so and the surrounding task lifecycle is designed to support that behavior.
Cancel a Task and Wait for Cleanup
A task should not be abandoned immediately after calling cancel(). Await it when possible so its cleanup can complete and its final state can be observed.
import asyncio
async def worker():
try:
while True:
await asyncio.sleep(1)
print("Working")
finally:
print("Worker cleanup completed")
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(2)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Worker stopped")
asyncio.run(main())This pattern is useful for individual tasks, but applications often have several related tasks. Managing them separately can make shutdown logic difficult to maintain.
For a group of related operations, Python 3.11 and later provide asyncio.TaskGroup, which supports structured concurrency.
Use TaskGroup to Manage Related Tasks
A TaskGroup manages the lifetime of tasks created inside its context. The group waits for its tasks when the context exits, and failures can trigger cancellation of the other tasks in the group.
import asyncio
async def worker(name):
try:
while True:
print(f"{name} is processing")
await asyncio.sleep(1)
finally:
print(f"{name} is cleaning up")
async def main():
async with asyncio.TaskGroup() as group:
group.create_task(worker("Worker A"))
group.create_task(worker("Worker B"))
await asyncio.sleep(3)
# Exiting normally does not cancel infinite tasks.
# A real shutdown signal should trigger cancellation
# or otherwise tell the workers to stop.
raise RuntimeError("Simulated application failure")
try:
asyncio.run(main())
except* RuntimeError as exc:
print("Task group failed:", exc)This example deliberately raises an error to demonstrate failure handling. The task group cancels its remaining tasks, waits for their cleanup, and propagates the failure as an exception group.
A TaskGroup does not automatically cancel all tasks just because the context exits normally. If workers are designed to run indefinitely, the application must define how they receive a shutdown signal or cancellation request.
For example, a service can use a shared event, a shutdown coordinator, or an explicit task-cancellation policy. The important part is to make the task lifetime clear rather than creating background tasks that no component owns.
Be Careful with asyncio.gather
asyncio.gather() is useful when several operations need to run concurrently. However, its cancellation behavior is not identical to a task group.
If the coroutine awaiting gather() is cancelled, cancellation is propagated to the submitted awaitables that are still running. But if one submitted operation raises an exception and gather() propagates that exception with its default settings, the other operations are not automatically cancelled merely because that exception was raised.
For example:
import asyncio
async def fetch_item(item_id):
try:
await asyncio.sleep(2)
return f"Item {item_id}"
finally:
print(f"Finished cleanup for item {item_id}")
async def main():
tasks = [
asyncio.create_task(fetch_item(1)),
asyncio.create_task(fetch_item(2)),
asyncio.create_task(fetch_item(3)),
]
try:
results = await asyncio.gather(*tasks)
print(results)
except asyncio.CancelledError:
for task in tasks:
task.cancel()
await asyncio.gather(
*tasks,
return_exceptions=True
)
raise
asyncio.run(main())This pattern explicitly cancels and awaits the tasks when the parent coroutine is cancelled. The second gather() uses return_exceptions=True so the cleanup wait can collect cancellation outcomes instead of immediately propagating them.
For new code that manages a closely related group of operations, TaskGroup is often easier to reason about. If you use gather(), understand which component owns the tasks and what should happen when one operation fails.
Handle Timeouts Without Leaking Tasks
Timeouts are another common source of cancellation. Python offers asyncio.wait_for() and, in Python 3.11 and later, the asyncio.timeout() context manager.
wait_for() normally cancels the awaited operation when the timeout expires and waits for the cancellation to be processed. The total elapsed time can exceed the requested timeout if the coroutine takes time to respond to cancellation.
import asyncio
async def slow_operation():
try:
await asyncio.sleep(10)
return "Completed"
finally:
print("Cleaning up slow operation")
async def main():
try:
result = await asyncio.wait_for(
slow_operation(),
timeout=2
)
print(result)
except TimeoutError:
print("Operation timed out")
asyncio.run(main())The timeout causes cancellation of the operation, allowing its finally block to run. The caller then receives TimeoutError.
Python 3.11 and later also support this form:
import asyncio
async def main():
try:
async with asyncio.timeout(2):
await slow_operation()
except TimeoutError:
print("Operation timed out")The timeout context manager cancels the current task when its deadline expires and translates the resulting cancellation into TimeoutError outside the context.
Neither approach can guarantee that an operation will stop at an exact deadline. A coroutine may perform lengthy cleanup or fail to cooperate with cancellation. If a task suppresses cancellation, timeout handling can become unpredictable.
Close Asynchronous Generators and Database Resources
Some resources need explicit asynchronous cleanup. This includes asynchronous generators, database connections, transactions, and streaming responses.
An asynchronous generator can hold resources while yielding multiple values. If iteration stops early, the application should ensure that the generator is closed rather than relying on garbage collection to release its resources at an unspecified time.
Python provides contextlib.aclosing() for this purpose:
import asyncio
from contextlib import aclosing
async def stream_items():
try:
for item in range(5):
await asyncio.sleep(1)
yield item
finally:
print("Stream resources released")
async def main():
async with aclosing(stream_items()) as items:
async for item in items:
print(item)
if item == 1:
break
asyncio.run(main())When the loop breaks, the aclosing() context manager calls the generator's asynchronous close method. The generator's finally block then runs.
For a database operation, the same principle applies: use the database driver's documented asynchronous context manager or close method. If the operation uses a transaction, ensure the transaction is committed or rolled back according to the driver's semantics.
Do not assume that cancelling a query automatically closes every connection, rolls back every transaction, or stops server-side work immediately. Those behaviors depend on the database driver and server.
Avoid Fire-and-Forget Tasks
A frequent source of resource leaks is creating a task without keeping track of it.
asyncio.create_task(send_notification())This starts a task, but the surrounding code does not retain a reference, await its result, or define how it should be cancelled during shutdown.
If the notification task fails, the application may not handle the failure properly. If the application is shutting down, the task may be left unfinished.
Prefer a TaskGroup when the task belongs to a structured group of operations. For genuinely long-lived background tasks, keep explicit references and provide a shutdown procedure that cancels and awaits them.
For example:
import asyncio
async def send_notification():
try:
await asyncio.sleep(5)
print("Notification sent")
finally:
print("Notification task finished")
async def main():
task = asyncio.create_task(send_notification())
try:
await asyncio.sleep(1)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(main())Here, the parent retains the task reference, requests cancellation, and waits for the task to finish. In a larger application, a dedicated task manager can centralize this lifecycle management.
Understand What Cancellation Cannot Stop
Cancellation works best with cooperative asynchronous operations. It cannot forcibly terminate arbitrary synchronous code.
For example, if a coroutine calls a blocking function that takes several seconds to return, the event loop may be blocked during that time. The cancellation request cannot be processed until control returns to the event loop.
Moving blocking work to a thread with asyncio.to_thread() can keep the event loop responsive, but cancelling the coroutine that awaits to_thread() does not reliably stop the underlying thread function. The thread may continue running until its function returns.
If the underlying work must stop, design it with its own cancellation mechanism, such as a thread-safe stop signal, a bounded operation, or a subprocess that can be terminated under a defined policy.
Cancellation also does not automatically undo external side effects. If a coroutine sends a payment request or writes to a remote service and is cancelled while waiting for the response, the operation may already have succeeded remotely. For important operations, use idempotency keys, transaction boundaries, or reconciliation logic rather than assuming cancellation means nothing happened.
Common Cancellation Mistakes
Several practices make asynchronous applications harder to shut down safely:
Catching
CancelledErrorand returning normally, which can hide cancellation from the caller.Calling
cancel()without awaiting the task, leaving cleanup and exceptions unobserved.Creating background tasks without storing references or defining their lifetime.
Performing blocking synchronous work directly on the event loop.
Assuming a timeout guarantees that the underlying operation has stopped.
Closing shared HTTP or database resources while other tasks are still using them.
Using
asyncio.shield()without a clear reason. Shielding can protect an inner awaitable from cancellation propagation, but it does not eliminate the need to track and eventually await that operation.Assuming cancellation rolls back remote side effects or database operations automatically.
These issues are best addressed by defining task ownership, using context managers for resources, and treating shutdown as an explicit part of the application's design.
Best Practices for Production Applications
Use try/finally for cleanup that must run when a coroutine exits. Prefer async with when a library offers an asynchronous context manager, and close asynchronous generators explicitly when iteration may end early.
Keep cancellation visible to the code responsible for task lifecycle management. Catch CancelledError only when necessary, perform the required handling, and re-raise it unless suppressing cancellation is an intentional part of the design.
For related tasks, use asyncio.TaskGroup when possible. For long-lived workers, implement a shutdown procedure that signals or cancels workers and waits for them to finish. Keep timeouts around operations that could otherwise wait indefinitely, but account for the time required to perform cleanup.
Finally, test cancellation directly. Cancel tasks while they are waiting for network responses, processing database results, iterating streams, or holding temporary resources. Verify that connections close, locks are released, transactions follow the expected policy, and no background tasks remain unexpectedly active.
Conclusion
Python asyncio cancellation is a request for a coroutine to stop, not an instruction to terminate it immediately. A well-designed task responds to that request, releases its resources, and allows the cancellation to propagate to the code managing its lifecycle.
Using try/finally, asynchronous context managers, structured task management, and explicit shutdown procedures helps prevent resource leaks and makes asynchronous applications more predictable under timeouts, failures, and application shutdowns.

Join the conversation! Your thoughts help the community grow.