skip to content

Why can't `__init__` await, and where must an async resource wrapper open its connection?

level: middleimportance: must knowfreq 55%

answer

  1. Building an object is not awaiting
  2. The constructor call has no await
  3. `__init__` must return None
  4. Acquire in `__aenter__`, release in `__aexit__`
  5. Otherwise an async classmethod factory

basics

~20 s

Construction is synchronous: __init__ must return None, so an async def __init__ returns a coroutine and raises TypeError. Put the awaited connect in __aenter__ and the awaited close in __aexit__, and use the object under async with.

solid answer

~50 s

Calling a class runs `__new__` then `__init__`, and Python checks that `__init__` returned `None`. Marking it `async def` makes it return a coroutine object instead, so you get `TypeError: __init__() should return None, not 'coroutine'` plus a never-awaited `RuntimeWarning` — and there is no `await` at the call site anyway. So `__init__` keeps only cheap configuration (the DSN, a timeout) with the resource attribute set to `None`; the awaited acquisition goes in `async def __aenter__`, which returns `self`, and the awaited release goes in `async def __aexit__`, which also runs when the body raised. The object is therefore only fully alive inside an `async with` block rather than constructed and passed around ready. If you truly need a ready object, use an `async def` classmethod factory — but then cleanup is the caller's discipline, not the language's.

code

python · 36 lines
python
import asyncio


class CatalogSession:
    def __init__(self, dsn: str) -> None:
        self.dsn = dsn          # cheap: no I/O, no await
        self.conn = None

    async def __aenter__(self) -> "CatalogSession":
        self.conn = await self._connect()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> bool:
        conn, self.conn = self.conn, None
        if conn is not None:
            await asyncio.sleep(0)   # stands in for an awaited close
        return False

    async def _connect(self) -> str:
        await asyncio.sleep(0)
        return f"open:{self.dsn}"

    async def fetch(self, accession: str) -> str:
        if self.conn is None:
            raise RuntimeError("CatalogSession used outside 'async with'")
        await asyncio.sleep(0)
        return f"{accession} via {self.conn}"


async def main() -> None:
    async with CatalogSession("museum-catalogue") as session:
        print(await session.fetch("acc-1017"))
    print(CatalogSession("museum-catalogue").conn)


asyncio.run(main())

go deeper

for a junior

Recall that async with is what awaits setup and teardown, and that a constructor cannot await. Be ready to say which two methods a class needs to be usable with async with.

for a middle

Explain the mechanics: type.__call__ requires __init__ to return None, so async def __init__ yields a coroutine and a TypeError. Show the split — cheap config in __init__, awaited acquire in __aenter__ returning self, awaited release in __aexit__.

for a senior

Demonstrate the operational judgement: guard methods against use before entry, pick the scope at which a long-lived session is entered, ensure release runs on the exception path, and explain why a background close task or __del__ is not a substitute for an awaited __aexit__.

for a principal

Own the API-shape tradeoff. Decide whether the codebase exposes resources as context managers only, as factories plus explicit aclose, or both, and be able to defend the choice — an 11-person team writing against a factory needs a review habit that catches missing finally blocks, which the context-manager form makes unnecessary.

### Construction in Python is synchronous, and the protocol says so When you call `Importer(dsn)`, `type.__call__` runs `__new__` to allocate the instance and then `__init__` to initialise it — and it checks what `__init__` returned: anything other than `None` is a `TypeError`. Declaring `async def __init__` does not make construction awaitable. The body no longer runs at call time; calling the class hands `type.__call__` a coroutine object, which raises `TypeError: __init__() should return None, not 'coroutine'`, and the abandoned coroutine also triggers `RuntimeWarning: coroutine ... was never awaited`. There is no `await` at the call site to suspend on, and the language will not insert one. `__new__` is called by the same synchronous machinery, so moving the work there changes nothing. So a wrapper around anything that must be *awaited* into existence — a connection handshake, a session login, a lease acquired from a pool — cannot obtain that state in its constructor. ### Where the awaited work goes: `__aenter__` and `__aexit__` The asynchronous context-manager protocol is Python's answer. `__aenter__` is an `async def` on the class; `async with` awaits it and binds its return value to the `as` name — return `self` when callers should get the wrapper itself. `__aexit__` is an `async def` taking `(exc_type, exc, tb)`; `async with` awaits it on the way out, on the normal path and the exception path alike, and its return value decides suppression: a falsy result (normally `None` or `False`) lets the exception propagate, a truthy one swallows it. Both are looked up on the *type*, not on the instance, like every special method — assigning a function to `obj.__aenter__` will not work. That yields a three-way split of responsibilities. `__init__` records cheap configuration: a DSN, a timeout, a retry budget, and the resource attributes set to `None`. `__aenter__` performs the awaited acquisition. `__aexit__` performs the awaited release. Release is the half people forget, and it is precisely why a synchronous `__exit__` will not do: a graceful close is often itself awaitable — draining a stream and awaiting `asyncio.StreamWriter.wait_closed`, sending a protocol goodbye, returning a lease. A sync `__exit__` cannot await any of it; spawning a background task from it is unordered and may be cancelled at loop shutdown. `__del__` is worse still: it runs at an unpredictable moment, possibly with no running event loop and possibly during interpreter shutdown, so it can only ever do the synchronous, best-effort part. ### The consequence an interviewer is actually probing The object is only fully alive *inside* its `async with` block. It cannot be built at import time, parked on a module attribute and passed around as a ready-to-use dependency; whoever holds it must have entered it. The practical corollaries: - **Guard the methods.** Raise `RuntimeError` with a clear message when the connection attribute is still `None`, rather than letting an `AttributeError` on `None` surface deep inside a call chain. - **Enter it once, at the right scope.** A pooled session for a museum-catalogue importer is entered at startup and exited at shutdown, so every unit of work reuses it instead of repeating the handshake. - **Nesting is normal**, and when the number of resources is only known at runtime a `contextlib.AsyncExitStack` holds them. - **Do not assume re-entrancy.** An instance whose `__aenter__` overwrites one attribute breaks when it is entered twice concurrently; that is a per-use object, not a shared one. ### The alternative, and what it costs If you genuinely need a fully-initialised object to hand around, use an asynchronous factory: an `async def` classmethod that awaits the setup and returns the built instance, so callers write `imp = await Importer.create(dsn)`. Construction stays synchronous and trivial; the awaiting happens inside a method, where it is legal. The price is that the language no longer guarantees cleanup — somebody must `await imp.aclose()` in a `finally`, which is exactly the discipline `async with` automates for you. The usual compromise is to write the awaited setup once in a private `async def`, have the factory await it, and have `__aenter__` await it and `return self`; the class then supports both styles from one code path. Two other patterns turn up in review and both are traps. Calling `asyncio.run` inside `__init__` to "just await it here" starts a second event loop and raises outright when a loop is already running. Connect-on-first-use — `await self._ensure()` at the top of every method — defers every failure to the first call and needs an `asyncio.Lock` to stop concurrent callers from opening several connections at once. A class may also define `__await__`, which makes `await Wrapper(dsn)` legal, but readers do not expect an awaitable instance and cleanup is still unowned. **What good looks like:** a cheap `__init__`, an awaited acquire in `__aenter__` that returns `self`, an awaited release in `__aexit__` that also runs when the body raised, methods that refuse to run before entry, and a documented scope at which the wrapper is entered.

  • What does returning `True` from `__aexit__` do, and why is that rarely right in a resource wrapper?
    A truthy return suppresses the exception that was propagating out of the `async with` body, so the block completes as if nothing failed. A wrapper's job is to release the resource, not to decide that a caller's error does not matter, so it should close and return a falsy value — `None` by default. Swallowing is only appropriate for a purpose-built suppressor, and even then it should be narrowed to specific exception types.
  • You want the wrapper usable both as `await Importer.create(dsn)` and under `async with`. How do you avoid two setup paths?
    Write the awaited acquisition once in a private `async def _open`. The classmethod factory awaits `_open` and returns the instance; `__aenter__` awaits the same `_open` and returns `self`; `__aexit__` and a public `aclose` both delegate to one private `async def _close`. Make `_open` idempotent — return early if the resource attribute is already set — so entering an already-opened instance does not connect twice.
  • Why is `__del__` a poor place to close an asynchronous connection?
    `__del__` runs whenever the last reference drops, which may be during garbage collection, on another thread, or at interpreter shutdown. It is synchronous, so it cannot await a graceful close, and there may be no running event loop to schedule one on; exceptions inside it are printed and ignored. At best it can do a synchronous best-effort release and warn that the object was never closed properly.

Printing a reading-room card for a museum archive is instant and offline — that is __init__. Being admitted takes a walk to the desk and a wait, and someone has to lock the door behind you afterwards; that is __aenter__ and __aexit__.

saying these in an interview costs you the question

  • Says `async def __init__` works if you await the class
  • Calls `asyncio.run` inside `__init__` to await the connect
  • Puts a blocking connect in `__init__` of an async class
  • Relies on `__del__` to close the connection
  • Thinks a plain `with` will await `__aenter__`
  • Says `__aexit__` must return `True` to propagate the error

context