What does Python's `async with` statement do that a plain `with` cannot, and which methods does it call?
answer
- Two protocols, not one
- Teardown that needs to do I/O
- Only legal inside async def
- A-prefixed pair of dunder methods
- Awaited result is bound, not the manager
basics
~10 sasync with awaits aenter before the block and awaits aexit after it, so setup and teardown can perform I/O. A plain with calls enter and exit synchronously and cannot await anything.
solid answer
~40 s`with` runs the synchronous protocol: it calls `__enter__`, binds the result to the `as` target, and calls `__exit__(exc_type, exc, tb)` on the way out. Neither method can `await`, because they are ordinary function calls. `async with` runs the asynchronous protocol instead: it awaits `__aenter__()` and awaits `__aexit__(exc_type, exc, tb)`, so both halves may suspend and do I/O. That is the whole point — returning a connection to a pool, sending a close frame, committing a transaction or flushing a batch are themselves awaitable operations. `async with` is only legal inside an `async def`, `__aexit__` is awaited on every exit path including `return`, `break` and a propagating exception, and awaiting a truthy result from `__aexit__` suppresses that exception exactly as the synchronous protocol does. If `__aenter__` raises, `__aexit__` is never called.
code
python · 8 linesfrom collections import deque
q = deque([1, 2, 3])
q.append(4) # O(1) on the right
q.appendleft(0) # O(1) on the left
print(q.popleft()) # 0
print(q.pop()) # 4
print(list(q)) # [1, 2, 3]go deeper
Be ready to name __aenter__ and __aexit__ on sight and to say why they exist: teardown that has to await. Know that async with only compiles inside an async def.
Explain the mechanics: the awaited result of __aenter__ is what the as target binds, __aexit__ receives the exception triple, and a truthy awaited result suppresses the exception.
Show the operational edges — __aexit__ running on every exit path, cancellation landing inside cleanup at an await point, and __aenter__ owning the rollback of anything it acquired before it failed.
Own the API-design angle: whether a resource should expose an async context manager, a bare aclose(), or both, and what that choice costs callers who need to hold the resource across function boundaries.
## The two protocols Python has two context-manager protocols, and they are separate. The **synchronous** one is a pair of ordinary methods. `with expr as name:` evaluates `expr` to get a manager object, calls `manager.__enter__()`, binds its return value to `name`, runs the body, and then calls `manager.__exit__(exc_type, exc, tb)` — with three `None`s on a clean exit, or with the live exception's class, instance and traceback if the body raised. The **asynchronous** one is a pair of methods that return awaitables, `__aenter__` and `__aexit__`, and it is driven by `async with`, added in Python 3.5 by PEP 492 alongside `async def` and `await`. The statement does the same choreography, except that it *awaits* each call: it awaits `manager.__aenter__()`, binds the awaited result to the `as` target, runs the body, and awaits `manager.__aexit__(exc_type, exc, tb)`. In practice both methods are written as `async def`, which makes them return coroutine objects — but the language only requires that they return something awaitable. ## Why the async half has to exist A synchronous `__exit__` cannot `await`. It is invoked by the interpreter as a plain call in the middle of executing your coroutine's frame; there is no suspension point available inside it. So any teardown that is itself I/O has nowhere to go. And async teardown is the common case, not an exotic one. Returning a pooled connection often means writing a reset command; closing a client session means draining in-flight work and sending a shutdown frame; committing a transaction is a round trip; flushing a batched writer sends the last batch. All of those are awaitable. `async with` exists so that the cleanup you must do can be expressed where cleanup belongs, instead of being deferred to a `close()` the caller is expected to remember. The same argument applies to setup. `__aenter__` can await a handshake, wait for a slot in a semaphore, or acquire a lock that suspends rather than blocks — `async with lock:` on an `asyncio.Lock` is exactly that. ## The exact semantics worth stating in an interview * `async with` is a syntax error outside an `async def` body (or another asynchronous context such as an async generator or async comprehension scope). * The `as` target receives **the awaited result** of `__aenter__()`, not the manager. Most managers `return self`, which is why the distinction is easy to miss — but a manager is free to hand back something else entirely. * The `as` clause is optional; `async with lock:` is complete. * One statement may hold several managers, `async with a() as x, b() as y:`, entered left to right and exited right to left. Since 3.10 that list may be wrapped in parentheses and split across lines. * `__aexit__` is awaited on **every** exit path: falling off the end, `return`, `break`, `continue`, an exception propagating, and cancellation. * If awaiting `__aexit__` produces a truthy value, the exception that was passed in is swallowed and execution continues after the block. Returning `False` (or `None`, by falling off the end) lets it propagate. Suppressing by accident — for example by ending `__aexit__` with an expression that happens to be truthy — is a real bug class. * If `__aenter__` raises, the block never runs and `__aexit__` is **not** called, exactly as in the synchronous protocol. Anything `__aenter__` acquired before failing must be cleaned up by `__aenter__` itself. * Because `__aexit__` is awaited, it contains suspension points, and a task cancelled during teardown can raise `asyncio.CancelledError` out of the middle of your cleanup. Cleanup that must complete has to be protected, for example with `asyncio.shield`. ## Standard-library examples The asynchronous protocol is not niche: `asyncio.Lock`, `asyncio.Semaphore` and the other asyncio primitives are async context managers; `asyncio.TaskGroup` and `asyncio.timeout` (both 3.11) are async context managers whose entire behaviour lives in `__aexit__`; `contextlib.aclosing` (3.10) wraps an object with an `aclose()` coroutine method; `contextlib.nullcontext` has supported the async protocol since 3.10. ## Mismatch is loud in one direction only Using `with` on an object that implements only the async protocol raises `TypeError` at the `with` statement, and CPython's message says so and suggests `async with`. The reverse mistake — a class whose `__exit__` is accidentally an `async def`, used under a plain `with` — is silent and much worse, which is a separate discussion in its own right. The one-line summary an interviewer wants: **`with` is `__enter__`/`__exit__` and cannot await; `async with` is `__aenter__`/`__aexit__` and awaits both, so setup and teardown may do I/O.**
- What is bound to the name after `as` in `async with open_feed() as feed:`?The awaited result of `__aenter__()`. The statement awaits the coroutine `__aenter__` returns and binds that value. Most managers `return self`, so it usually looks like the manager itself, but nothing requires that — a manager can enter a resource and hand back a cursor, a session or a plain tuple.
- If `__aexit__` awaits successfully but returns True, what happens to an exception raised in the block?It is suppressed. The truthy result tells the statement the exception has been handled, so execution resumes after the `async with` block and the exception never propagates. This is the same rule as synchronous `__exit__`, and it is why cleanup methods should end with an explicit `return False` or simply fall off the end rather than returning whatever the last expression evaluated to.
- Is `__aexit__` still awaited when the block exits via `return` or an unhandled exception?Yes, on every exit path — falling off the end, `return`, `break`, `continue`, a propagating exception, and cancellation. The only case where it is not called is when `__aenter__` itself raised, because the manager was never successfully entered; anything `__aenter__` acquired before failing must be released inside `__aenter__`.
A plain with is a door that latches shut the instant you let go. async with is a door with a closer that needs a moment to run — the statement waits for it before moving on.
saying these in an interview costs you the question
- Says `async with` is just `with` inside async code
- Claims `__enter__` can await if the function is async
- Thinks the `as` name is always the manager object
- Believes `__aexit__` is skipped when the block raises
- Cannot name `__aenter__` and `__aexit__` at all