skip to content

Why does contextlib.ExitStack.enter_context reject an async context manager, and what replaces it?

level: middleimportance: should knowfreq 34%

answer

  1. One machine cannot await, the other can
  2. The failure is a type error, not a hang
  3. A stack for resources counted at runtime
  4. The async stack still owns the sync methods
  5. enter_async_context and push_async_callback

basics

~10 s

contextlib.ExitStack drives the synchronous protocol only, so passing it an object that defines aenter and aexit raises TypeError. Use contextlib.AsyncExitStack instead, entering resources with await stack.enter_async_context(cm).

solid answer

~40 s

`contextlib.ExitStack` calls `__enter__` and `__exit__` directly and has no way to await anything, so `enter_context` on an object that only implements `__aenter__`/`__aexit__` raises `TypeError` about the object not supporting the context manager protocol. The replacement is `contextlib.AsyncExitStack`, used as `async with AsyncExitStack() as stack`, where each asynchronous resource is registered with `await stack.enter_async_context(cm)`. It also offers `push_async_callback(func, *args)` to schedule a coroutine function for unwinding and `await stack.aclose()` to unwind early. Crucially it inherits the synchronous methods too — `enter_context`, `callback`, `push` and `pop_all` — so one stack can hold a mix of synchronous and asynchronous resources and still unwind them all in reverse order.

code

python · 15 lines
python
import asyncio
from contextlib import ExitStack, asynccontextmanager

@asynccontextmanager
async def probe():
    yield 1

async def main():
    with ExitStack() as stack:
        try:
            stack.enter_context(probe())
        except TypeError as exc:
            print(type(exc).__name__, exc)

asyncio.run(main())

go deeper

for a junior

Recognise the TypeError and know its cure: an object with aenter belongs on contextlib.AsyncExitStack, entered with await stack.enter_async_context(cm), not on the synchronous stack.

for a middle

Explain why the synchronous stack cannot work here at all, name enter_async_context, push_async_callback and aclose, and know that one AsyncExitStack can carry synchronous resources too.

for a senior

Show when a stack beats nested blocks — a runtime-sized set of resources, partial-failure unwinding — and use pop_all to hand a fully built bundle to a longer-lived owner without losing cleanup on the failure path.

for a principal

Own the resource-lifetime convention across a service: who holds the stack, where it is closed, and how a partially-built set of async resources is guaranteed never to escape as a leak under failure.

### The refusal, precisely `contextlib.ExitStack` is a purely synchronous machine. `enter_context(cm)` looks up `__enter__` and `__exit__` on the *type* of the object, calls `__enter__` immediately, and pushes a callback that will call `__exit__` during unwinding. An object produced by `@contextlib.asynccontextmanager`, or any class implementing `__aenter__`/`__aexit__`, has neither method, so the lookup fails and `enter_context` raises `TypeError` — *object does not support the context manager protocol*. Nothing is entered, nothing is registered, and the resource is left unopened. That is the good outcome: the alternative would be a half-opened resource with no scheduled cleanup. A detail worth noticing: the `with` **statement** produces a friendlier version of the same error, adding that the object supports the asynchronous protocol and asking whether you meant `async with`. `ExitStack.enter_context` gives the bare message with no such hint, so recognising the plain wording matters when the failure comes from a stack rather than a `with`. ### The replacement `contextlib.AsyncExitStack` is the async-capable counterpart, and it is itself an asynchronous context manager, so it is used as `async with AsyncExitStack() as stack`. Its additions are: - `await stack.enter_async_context(cm)` — awaits `cm.__aenter__()`, returns its value, and schedules `cm.__aexit__` for unwinding. - `stack.push_async_callback(func, *args, **kwargs)` — registers a coroutine function to be awaited during unwinding. Like the synchronous `callback`, it is for cleanup that is a plain call rather than a context manager, and the return value it registers is ignored, so it can never suppress an exception. - `stack.push_async_exit(exit)` — registers an object's `__aexit__` (or a coroutine function with that signature) without entering anything, which is what you want when a resource was opened elsewhere and only its release needs to be owned by the stack. - `await stack.aclose()` — unwinds everything immediately, the async analogue of closing the stack early. All of these are *awaited* at unwind time, which is precisely what a synchronous stack cannot do. ### One stack, both flavours `AsyncExitStack` inherits the synchronous half of the API — `enter_context`, `callback`, `push` and `pop_all` all exist on it. That is the practical point most candidates miss: you do **not** need two stacks when a routine mixes an opened file with an awaited session. Register each resource with the method matching its own protocol and the single stack unwinds them all in strict reverse order, awaiting the async ones and calling the sync ones. Keeping one stack preserves the true release order across the mixture; two parallel stacks cannot. ### Why a stack at all The stack earns its place when the number of resources is not known when the code is written: a variable-length list of endpoints, an optional resource entered only under a flag, or a set built from configuration. Nesting `async with` blocks demands a fixed, statically-written shape; a stack lets a loop enter as many as it finds while still guaranteeing that everything entered is released, including when entry number four raises after three succeeded. ```python async with AsyncExitStack() as stack: sessions = [await stack.enter_async_context(open_probe(name)) for name in probe_names] ``` If `open_probe` fails on the fifth name, the four already entered are unwound before the exception leaves the block. ### Transferring ownership `pop_all()` is inherited and works the same way as on the synchronous stack: it moves every registered callback to a fresh stack and clears the original, so exiting the original block releases nothing. The idiom is to build resources under the safety of a stack, and — only once every step has succeeded — transfer them to a long-lived object that will unwind them later with `await stack.aclose()`. It converts an all-or-nothing setup routine into a factory that hands back a live, owned resource bundle. ### Unwinding a mixed stack Unwinding runs the registered exits in strict reverse order of registration, awaiting each asynchronous one in turn. It is sequential, not concurrent: an `AsyncExitStack` is a cleanup ledger, not a way to release several resources at once, so a teardown that awaits something slow costs that time at every exit. If one exit itself raises, the remaining exits still run and the errors chain, so the first failure is not lost — but a cleanup routine that can fail is still worth wrapping so it cannot mask the exception that caused the unwinding in the first place. ### Common failure modes Forgetting `await` in front of `enter_async_context` binds a coroutine object rather than the resource, and the block then operates on something that is not the session at all. Passing a *coroutine object* to `push_async_callback` instead of the function plus its arguments creates a never-awaited coroutine and a warning at teardown. And entering the stack itself with plain `with` fails immediately, since `AsyncExitStack` implements only the asynchronous protocol.

  • Do you need a second stack when a routine opens both a file and an awaited session?
    No. `AsyncExitStack` inherits `enter_context`, `callback`, `push` and `pop_all`, so the file goes on with `enter_context` and the session with `await enter_async_context`, on the same stack. Unwinding then follows one strict reverse order across both kinds, awaiting the asynchronous exits and calling the synchronous ones. Two parallel stacks would lose that single ordering.
  • What is the difference between push_async_callback and enter_async_context?
    `enter_async_context` awaits `__aenter__` now, hands you its value and schedules `__aexit__`. `push_async_callback` enters nothing: it just records a coroutine function and its arguments to be awaited during unwinding. Use the first for a real asynchronous context manager, the second for cleanup that is only a call — flushing a buffer, cancelling a subscription. A callback's result is ignored, so it can never suppress an exception.
  • How would you build several async resources safely but keep them alive past the block?
    Enter them all under an `AsyncExitStack`, then call `pop_all()` as the last statement of the block. That transfers every registered exit to a new stack, so leaving the original block releases nothing, while a partial failure before that line still unwinds cleanly. Store the returned stack on the owning object and unwind it later with `await stack.aclose()`.

saying these in an interview costs you the question

  • Thinks ExitStack silently awaits an async manager
  • Believes a sync manager needs its own separate stack
  • Omits await before enter_async_context
  • Passes a coroutine object to push_async_callback
  • Enters AsyncExitStack with a plain with statement
  • Expects a callback return value to suppress an exception

context