Skip to content
← All articles

Async Context Managers: __aenter__ Returns Self, __aexit__ Returns bool | None

A cross-reference to 5.24 and 7.4, plus the two signatures worth getting right: Self on the way in, and why annotating __aexit__ as -> bool invites silent exception suppression.

This item is a cross-reference. The machinery lives elsewhere in the course:

  • 5.24 — asynccontextmanager, aclosing, AsyncExitStack owns the library side: how to build one from a generator, how to stack several, and why an abandoned async generator does not release its connection promptly.
  • 7.4 — Async iterators and async generators owns the iteration side: __aiter__/__anext__, StopAsyncIteration, and what breaking out of an async for leaves suspended.

What concurrency adds is two signatures that are easy to get subtly wrong and impossible to notice afterwards.

__aenter__ returns Self

from typing import Self

class Session:
    async def __aenter__(self) -> Self:
        await self._connect()
        return self

Not -> "Session". Self (3.11, and in typing_extensions before that) is what makes the annotation survive subclassing: with -> Session, a class AuthedSession(Session) used as async with AuthedSession() as s: gives you an s typed Session, and every subclass-specific attribute you reach for afterwards is an attr-defined error you then “fix” with a cast.

This is not an async-specific rule — it applies equally to __enter__ — but async context managers are where it bites most, because the whole point of the object is usually to hold a resource whose subclass-specific API you are about to use.

__aexit__ returns bool | None, and the difference matters

The full signature:

async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> bool | None:

The return value is a suppression flag. A truthy return tells Python “I have handled the exception; do not propagate it”. None and False both mean “let it through”.

Now consider the annotation -> bool. It type-checks. It runs. And it invites exactly one mistake:

async def __aexit__(self, exc_type, exc, tb) -> bool:
    await self._close()
    return True          # <-- looks like "cleanup succeeded"

That True does not mean “cleanup succeeded”. It means every exception raised inside the async with block is now silently swallowed. Not logged, not re-raised, not visible: the block simply appears to have completed. It is one of the quietest bugs you can write, and the -> bool annotation is what makes it look natural — a function annotated -> bool wants to return True or False, and neither is the answer you want.

Annotate it -> bool | None and end the method with no return at all. The type then reads as “usually nothing, occasionally a deliberate suppression”, which is the truth.

💡contextlib.asynccontextmanager turns a generator into an async context manager. How does suppression work there, and can you make the same mistake? click to reveal

Differently, and yes — with a different spelling.

In the generator form there is no __aexit__ to annotate. The exception is thrown into the generator at its yield, so you handle it with an ordinary try/except around the yield:

@asynccontextmanager
async def session() -> AsyncIterator[Session]:
    s = await connect()
    try:
        yield s
    finally:
        await s.close()

Suppression happens if your except around the yield catches and does not re-raise — which reads much more obviously as “I am swallowing this” than return True does. That is a real argument for the generator form.

The generator form has its own trap, though, and it is the mirror image: if the generator does not yield again after the exception, __aexit__ returns falsey and the exception propagates, but if you write except Exception: yield you get RuntimeError: generator didn't stop after throw(). And the annotation AsyncIterator[Session] — not AsyncGenerator[Session, None] — is what the decorator’s own typeshed signature expects.

The one concurrency-specific rule

__aexit__ runs during cancellation too. If the enclosing task is cancelled inside the block, exc_type is asyncio.CancelledError, and everything from item 9.10 applies: your cleanup may await, it must be bounded, and it must not suppress. Returning truthy from __aexit__ when exc_type is CancelledError is how a task refuses to be cancelled without anyone writing the words except CancelledError.