Back to Blog
Python

Python Async Context Manager: Implementation and Usage

Learn how to implement and use python async context managers with __aenter__/__aexit__ and asynccontextmanager, including error handling and resource cleanup.

asynccontext managerasynciocontextlibresource management
Illustration of an async context manager managing an asynchronous resource with enter and exit phases.

A python async context manager follows the same protocol as a regular context manager but uses coroutine methods for setup and teardown. It is designed for resources that require asynchronous operations, such as opening a network connection or acquiring a lock in an asyncio application. The async with statement drives the lifecycle, calling __aenter__ on entry and __aexit__ on exit.

The async with Statement and Its Protocol

The async with statement is the asynchronous counterpart of with. It requires an object that implements the asynchronous context manager protocol: __aenter__ and __aexit__ must be coroutine functions (or return awaitables). When execution reaches async with, the interpreter awaits __aenter__, binds its return value to the target variable, and later awaits __aexit__ when the block finishes, even if an exception occurs.

class AsyncResource: async def __aenter__(self): await self.connect() return self async def __aexit__(self, exc_type, exc, tb): await self.close() async def use_resource(): async with AsyncResource() as res: await res.do_work()

The __aexit__ method receives the exception type, value, and traceback if an exception was raised inside the block. Returning True from __aexit__ suppresses the exception; returning False or None lets it propagate.

Implementing aenter and aexit

When you control the class, you can implement the protocol directly. The methods must be coroutines, which means they are defined with async def. The setup and teardown logic can include await calls, making it possible to open connections, start background tasks, or release locks asynchronously.

class AsyncLock: def __init__(self): self._lock = asyncio.Lock() async def __aenter__(self): await self._lock.acquire() return self async def __aexit__(self, exc_type, exc, tb): self._lock.release()

A common pattern is to return self from __aenter__ so the bound name refers to the object itself. If you need to return a different object, such as a cursor or session, you can do so as long as the caller knows what to expect.

Using contextlib.asynccontextmanager

Writing classes for every resource can be verbose. The contextlib.asynccontextmanager decorator lets you create an async context manager from a single generator function. The function must yield exactly once, and the code before the yield runs on entry while the code after the yield runs on exit.

from contextlib import asynccontextmanager @asynccontextmanager async def managed_session(url): session = await create_session(url) try: yield session finally: await session.close()

The try/finally ensures cleanup runs even if the body raises. This approach is often easier to read than a full class when the resource logic is short. It also works well with dependency injection and factory functions.

Error Handling and Cleanup in Async Context Managers

The cleanup behavior of async with is identical to with in terms of exception propagation. If the body raises an exception, __aexit__ is awaited with the exception details. This gives you a chance to handle rollback, session closing, or error logging.

@asynccontextmanager async def db_transaction(conn): try: yield conn except Exception: await conn.rollback() raise else: await conn.commit()

If __aexit__ itself raises an exception, that exception replaces any exception from the body. This can hide the original failure, so keep cleanup logic defensive and avoid raising in __aexit__ unless you have a specific reason.

Common Use Cases for Async Context Managers

Async context managers appear wherever a resource has asynchronous lifecycle operations. HTTP clients like aiohttp provide async context managers for sessions. Database drivers such as asyncpg use them for connections and transactions. Network servers and clients often use them for connection handling. Even simple synchronization primitives, such as asyncio.Lock, can be wrapped to avoid manual acquire/release calls.

async with aiohttp.ClientSession() as session: async with session.get(url) as response: data = await response.text()

Using async with for these resources reduces the risk of forgetting to close them, especially when exceptions occur mid-operation.

Performance and Resource Management Considerations

The async context manager adds negligible overhead compared to manual await calls. The main cost is the extra coroutine creation for __aenter__ and __aexit__, which is typically small relative to the I/O operations they wrap. The real benefit is deterministic cleanup: resources are released as soon as the block exits, which matters for connection pools, file descriptors, and memory usage in long-running asyncio processes.

One point to watch is that __aexit__ is awaited even if the task is cancelled. If your cleanup performs blocking operations, it can delay cancellation handling. Prefer non-blocking cleanup and avoid long-running operations in __aexit__ when possible.

Common Pitfalls and How to Avoid Them

A frequent mistake is using with instead of async with on an async context manager, which raises AttributeError because the object has no __enter__. Another is forgetting to make __aenter__ and __aexit__ coroutines; if they are regular functions, async with will fail at runtime. When using asynccontextmanager, ensure the generator yields exactly once; yielding more than once raises RuntimeError.

Also be careful with exception suppression. Returning True from __aexit__ hides the exception, which is rarely what you want. Only suppress exceptions when you have a specific recovery strategy, and document that behavior clearly.

python async context manager: Practical Usage and Code Examp | RYUSLOG DEV