Skip to content

← Laziness, Iteration and Pipelines step 6 of 14

Medium Primitives

Async iterators: the paginator you can actually close

Breaking out of an async for leaves the iterator suspended. For an async generator, its finally block then runs whenever the event loop gets round to finalising it — possibly during loop shutdown, possibly long after the connection you meant to release was needed by someone else. “It runs eventually” is not a resource-management strategy.

contextlib.aclosing (3.10) makes it deterministic: the aclose() happens at the closing brace of the async with, synchronously with respect to your code.

Two protocol facts worth memorising:

  • __aiter__ must not be async def. It is a plain method that returns the async iterator. Returning an awaitable from it was deprecated in 3.5.2 and removed in 3.7. Write async def __aiter__ today and async for raises a TypeError at the first iteration.
  • aiter() and anext() are builtins as of 3.10, and anext(it, default) is the async analogue of next(it, default): it returns the default instead of raising StopAsyncIteration.

What to write

The three protocol methods of Paginator[T]. __init__, the fetch callable, and the driver are provided.

class Paginator[T]:
    def __init__(self, fetch: Callable[[int], Awaitable[list[T]]]) -> None: ...
    def __aiter__(self) -> Self: ...
    async def __anext__(self) -> T: ...
    async def aclose(self) -> None: ...
  • __aiter__ returns self. Not async def.
  • __anext__ serves the next buffered item. When the buffer is empty it awaits self._fetch(self._page) for the next page and advances _page. An empty page ends the stream — set _exhausted and raise StopAsyncIteration. A page in the middle that comes back empty also ends it; that is the documented contract of this API, not an accident.
  • aclose() marks the paginator closed and drops the buffer. Every later __anext__ must raise StopAsyncIteration — which is what makes anext(paginator, sentinel) return the sentinel.

What the report proves

  • items — the flattened pages, in order.
  • fetch_calls — how many times the network was actually touched. With stop_after=2 over a first page of two items, this must be 1: a paginator that prefetches the next page while you are still consuming the current one is a paginator that costs money for rows nobody read.
  • closedTrue after the async with aclosing(...) block, on every path, including an early break.
  • after_close — the value of await anext(paginator, sentinel) once the paginator is closed or exhausted. It must be the sentinel.

Types

Self (PEP 673, 3.11) is the correct return annotation for __aiter__; a subclass then gets the subclass type back rather than the base. Annotating it AsyncIterator[T] would compile but throws that away.

Loading visualization…