Back to Blog
Python

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.

asynccontext managersasynciopythonresource management
Diagram showing async with entering and exiting an async context manager in Python

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 the as target.
  • 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.Lock and other synchronization primitives
  • aiohttp.ClientSession for HTTP requests
  • asyncpg connection pools
  • aiomysql connections

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 with is only useful inside an event loop. If you are writing synchronous code, a regular with is 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 with instead of with when the context manager is asynchronous. This raises a TypeError because __enter__ is not defined.
  • Using async with on 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, and async with will raise an error.
  • Mixing async with and with in the same block without understanding the difference. For example, opening a file with with open(...) is fine, but if you need to use an async file library, you must use async 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().

python async with: Async Context Managers | RYUSLOG DEV