python async with: Async Context Managers
Learn how python async with works, how to implement async context managers, and when to use them for proper resource cleanup in asyncio code.
python async with requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
What async with Does
async with is the asynchronous counterpart of the regular with statement. It allows you to work with context managers that perform asynchronous operations during setup and teardown. For example, acquiring a lock, opening a network connection, or starting a database transaction often require await expressions. A regular with block cannot await anything, so Python provides async with for these cases.
The syntax is straightforward:
async with some_async_context_manager() as resource: # use the resource
When execution reaches the async with line, Python calls __aenter__() on the context manager and awaits its result. At the end of the block, it calls __aexit__() and awaits that result as well. This ensures that cleanup operations that need to be awaited are properly handled.
The Async Context Manager Protocol
An object can be used with async with if it implements two special methods:
async def __aenter__(self): called when entering the block; its return value is bound to theastarget.async def __aexit__(self, exc_type, exc, tb): called when leaving the block; it can handle exceptions and return a boolean to suppress them.
These methods are the async counterparts of __enter__ and __exit__. The key difference is that they are coroutines, so they must be awaited.
A minimal implementation looks like this:
class AsyncResource: async def __aenter__(self): await self.connect() return self async def __aexit__(self, exc_type, exc, tb): await self.close()
When you use async with AsyncResource() as res:, Python will await __aenter__() first, then execute the block, and finally await __aexit__().
Using Built-in Async Context Managers
Many asyncio libraries provide objects that work with async with. Common examples include:
asyncio.Lockand other synchronization primitivesaiohttp.ClientSessionfor HTTP requestsasyncpgconnection poolsaiomysqlconnections
For instance, an asyncio.Lock is typically used like this:
lock = asyncio.Lock() async def critical_section(): async with lock: # protected code await do_work()
The lock is acquired when entering the block and released when exiting, even if an exception occurs. This is the same guarantee you get from a regular with block, but with the ability to await the acquisition and release operations.
Implementing Your Own Async Context Manager
You will often need to create your own async context manager to wrap resources that require asynchronous setup and cleanup. The pattern is simple, but there are a few details to keep in mind.
Suppose you have a class that manages a connection pool. You want to ensure that a connection is checked out and then returned, with proper error handling. An async context manager is a natural fit:
class PoolConnection: def __init__(self, pool): self.pool = pool self.conn = None async def __aenter__(self): self.conn = await self.pool.acquire() return self.conn async def __aexit__(self, exc_type, exc, tb): await self.pool.release(self.conn)
Now callers can write:
async with PoolConnection(pool) as conn: await conn.execute("SELECT ...")
If an exception is raised inside the block, __aexit__ still runs, so the connection is released. That is the main advantage over manually managing the lifecycle with try/finally.
Exception Handling in async with
The __aexit__ method receives the exception details if an exception occurred inside the block. You can inspect them and decide whether to suppress the exception. Returning True from __aexit__ suppresses the exception; returning False (or None) lets it propagate.
For example, you might want to retry a failed operation by swallowing the exception and letting the caller decide:
class RetryContext: async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): if exc_type is TimeoutError: return True # suppress timeout return False
But be careful: suppressing exceptions can hide real problems. Only use this when you have a specific reason, such as a known transient failure that you want to handle at a higher level.
Performance and Concurrency Considerations
Using async with does not add significant overhead compared to manual await calls. The real benefit is correctness and readability. However, there are a few performance-related points to keep in mind:
async withis only useful inside an event loop. If you are writing synchronous code, a regularwithis the right choice.- The
__aenter__and__aexit__methods are awaited, so they can be suspended. This allows other tasks to run during I/O operations, which is the core advantage of asyncio. - Avoid doing heavy synchronous work inside
__aenter__or__aexit__; it will block the event loop. If you need to perform CPU-bound cleanup, consider offloading it to a thread or process.
In practice, the overhead of the context manager protocol itself is negligible compared to the I/O operations it wraps.
Common Mistakes and Edge Cases
Several pitfalls trip up developers new to async with:
- Forgetting to use
async withinstead ofwithwhen the context manager is asynchronous. This raises aTypeErrorbecause__enter__is not defined. - Using
async withon a regular context manager. That will also fail, because__aenter__is missing. - Not awaiting inside
__aenter__or__aexit__. If you define them as regular methods, they won't be coroutines, andasync withwill raise an error. - Mixing
async withandwithin the same block without understanding the difference. For example, opening a file withwith open(...)is fine, but if you need to use an async file library, you must useasync with.
Another edge case is when __aexit__ itself raises an exception. If both the block and __aexit__ raise, the __aexit__ exception propagates and the original exception is chained. This is similar to regular with behavior.
Finally, remember that async with can only be used inside a coroutine function. You cannot use it at the top level of a script unless you are running inside an event loop, for example with asyncio.run().