skip to content

A `with` block whose `__exit__` is an `async def` silently swallows errors. Why, and how do you catch it?

level: seniorimportance: should knowfreq 38%

answer

  1. The statement never awaits anything
  2. Calling it only builds an object
  3. That object has no __bool__
  4. Truthy exit result means suppress
  5. Warnings-as-errors makes it visible

basics

~20 s

A plain with calls exit and gets back a coroutine object it never awaits, so cleanup never runs. Coroutine objects are truthy, and a truthy exit result means suppress, so exceptions vanish. Only a RuntimeWarning marks it.

solid answer

~40 s

The synchronous `with` statement calls `__exit__(exc_type, exc, tb)` and inspects the returned value. If `__exit__` is an `async def`, calling it just builds a coroutine object; the body never runs, so no cleanup happens. Worse, the statement then evaluates that coroutine object for truthiness to decide whether to suppress the exception — and every coroutine object is truthy — so any error raised inside the block is silently swallowed. The only evidence is `RuntimeWarning: coroutine '...__exit__' was never awaited`, which is off in most log configurations. The mirror-image mistake is loud: a plain `with` on an object that defines only `__aenter__`/`__aexit__` raises `TypeError` immediately, and CPython's message even suggests `async with`. Catch the silent one by running warnings as errors, or under `-X dev`.

code

python · 11 lines
python
from collections import deque

window = deque(maxlen=3)
for x in range(5):
    window.append(x)
print(list(window))    # [2, 3, 4]

window.appendleft(99)
print(list(window))    # [99, 2, 3]
print(window.maxlen)   # 3
print(len(window) == window.maxlen)  # True

go deeper

for a junior

Remember that calling an async def runs nothing — it hands back a coroutine object that must be awaited. Any warning about a coroutine never being awaited means real code did not execute.

for a middle

Explain why the swallowing happens: the synchronous with uses the truthiness of the __exit__ result to decide suppression, and an un-awaited coroutine object is always truthy.

for a senior

Show how you would find this in a running service — warnings promoted to errors, development mode for coroutine origin tracking, asyncio debug mode for pending-task destruction — and why bridging with asyncio.run or create_task is not a fix.

for a principal

Set the policy: managers are wholly sync or wholly async, blocking resources are isolated behind a worker thread, and the never-awaited warning is an error in CI rather than a line in a log nobody reads.

## The scenario A museum-catalogue importer maintained by an 11-person team started dropping records. Rows with a clock-skew artefact — an item timestamp earlier than its accession date — were supposed to raise, be counted and be re-queued. Instead the run reported a clean import and the rows were simply gone. The validation code did raise. Nothing caught it. The importer's own resource wrapper ate it. ## What the interpreter actually does The synchronous `with` statement is defined in terms of two plain calls. On exit it does the moral equivalent of: ```python result = mgr.__exit__(exc_type, exc, tb) if exc is not None and result: # exception suppressed; fall through past the block ``` There is no awaiting anywhere in that. So if `__exit__` was written as `async def`, calling it does what calling any coroutine function does: it constructs a **coroutine object** and returns it without executing a single line of the body. Two consequences follow immediately. **Cleanup never runs.** The connection is not returned to the pool, the batch is not flushed, the transaction is not rolled back. The coroutine is garbage-collected un-started. **Exceptions are suppressed.** A coroutine object is an ordinary object with no `__bool__` or `__len__`, so it is truthy. The `with` statement reads that truthiness as "the manager handled it" and drops the exception on the floor. The block appears to have succeeded. CPython does emit `RuntimeWarning: coroutine 'Importer.__exit__' was never awaited` — but `RuntimeWarning` is displayed once per location by the default filters, is routinely suppressed by application logging configuration, and points at the `with` line rather than at the vanished error, so it reads like noise. ## The mirror-image mistake is harmless Going the other way is loud. Using a plain `with` on an object that implements only `__aenter__` and `__aexit__` raises at the statement itself: ``` TypeError: 'Session' object does not support the context manager protocol (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'? ``` That is a good error: it fires before the body runs, and it names the fix. It is worth saying in an interview that the two mismatches are not symmetric — one is a compile-fast failure, the other is a silent data-loss bug. ## The bridges people try, and why they fail Once someone realises cleanup needs to await from inside a synchronous `__exit__`, three workarounds get attempted: * **`asyncio.run(...)` inside `__exit__`.** Fails with `RuntimeError: asyncio.run() cannot be called from a running event loop`, because the thread already has one. Even where it doesn't fail — cleanup happening on a *different* loop — it is wrong: the resource belongs to the original loop. * **`loop.run_until_complete(...)`.** Same problem, same reason: a loop cannot be re-entered. * **`asyncio.create_task(self.aclose())` and return.** This one is the trap that looks like it works. Teardown is scheduled but not awaited, so `with` returns before anything closed; if the program finishes or the loop shuts down first, the task is destroyed pending and you get "Task was destroyed but it is pending!" — or nothing at all. ## The actual fixes * **Make the manager async.** Rename to `__aenter__`/`__aexit__`, use `async with`, and the awaiting happens where it belongs. * **Expose `aclose()` and wrap it.** `contextlib.aclosing(obj)` (3.10) awaits `obj.aclose()` on exit, which is enough when there is no interesting entry step. * **Push the sync resource to a thread.** If the resource genuinely is synchronous and blocking, keep its `with` block inside a function run by `asyncio.to_thread`, so the blocking `__exit__` runs off the event loop. * **Never write a class that has one sync and one async half.** A manager is either fully synchronous or fully asynchronous; a hybrid pair is the bug. ## Detection, which is the part seniors are actually asked about * Turn the warning into a failure in tests and CI: `PYTHONWARNINGS=error::RuntimeWarning`, or `-W error::RuntimeWarning` on the command line. A never-awaited coroutine then crashes the test that produced it. * Run under **development mode**, `-X dev` or `PYTHONDEVMODE=1`. It enables the default warning filters plus coroutine origin tracking, so the never-awaited warning arrives with the traceback of where the coroutine was created. * Turn on asyncio debug mode, `PYTHONASYNCIODEBUG=1` or `asyncio.run(main(), debug=True)`, which additionally reports tasks destroyed while pending — the signature of the `create_task` "fix". * Review rule for the codebase: a class defining `__exit__` must not define any `async def`, and vice versa. This is trivially greppable and a static-analysis rule can enforce it. The diagnostic instinct to demonstrate: **an exception that disappears without a trace is a suppression bug, and in async Python the first suspect is a context manager whose exit half was never awaited.**

  • Why is the opposite mistake — `with` on an async-only manager — much safer?
    Because the statement checks for `__exit__` before running the body and raises `TypeError` at once, with a message naming the missing method and suggesting `async with`. Nothing has been acquired, no exception is in flight to swallow, and the traceback points at the offending line. It fails before it can do damage, whereas the async-`__exit__` case fails after the damage.
  • Someone proposes calling `asyncio.create_task(self.aclose())` from a synchronous `__exit__`. What breaks?
    The `with` block returns before the cleanup has run, so callers proceed against a resource they believe is closed. Ordering is unspecified, and if the program finishes or the loop shuts down before the task is scheduled it is destroyed pending — the resource never closes and you get a "Task was destroyed but it is pending" report at best, silence at worst.
  • The resource really is synchronous and blocking. How do you use it from a coroutine?
    Keep the ordinary `with` block inside a plain function and run that function with `asyncio.to_thread`. The blocking acquire and release then happen on a worker thread instead of on the event loop, and the coroutine awaits the whole unit. Do not try to bridge the two protocols inside the manager itself.

It is like handing someone a sealed envelope of instructions and walking away: nothing in it was carried out, but you both act as though the job is done.

saying these in an interview costs you the question

  • Assumes an unawaited coroutine returns None or is falsy
  • Says Python awaits `__exit__` automatically when needed
  • Proposes asyncio.run inside a synchronous __exit__
  • Fires cleanup with create_task and returns immediately
  • Treats the never-awaited RuntimeWarning as harmless noise
  • Thinks both mismatch directions fail the same way

context