2025, Oct 03 03:00
Why await in Python asyncio requires an iterator: understanding __await__, generators, and yield
Learn why Python asyncio await requires an iterator: how __await__, generators, yield, and the event loop enable cooperative multitasking and efficient I/O.
Why await requires an iterator in Python asyncio
When learning Python’s asyncio, a common stumbling block is understanding why await only accepts awaitable objects that expose a __await__ method returning an iterator. The short answer is that Python’s async model is cooperative multitasking built on top of generators, and yield is the mechanism that pauses and resumes execution. The iterator interface is the way that pause-and-resume is expressed in Python code.
Minimal example that surfaces the question
async def runner():
    data = await rendezvous
    print(data)
class Rendezvous:
    def __await__(self):
        yield "Hello"
        return "World"
rendezvous = Rendezvous()
it = runner().__await__()
first_step = next(it)
Calling runner() gives you a coroutine object. When the coroutine hits await rendezvous, Python expects rendezvous to be awaitable. In Python terms, that means it provides __await__() and that this method returns an iterator. Internally, await rendezvous can be reduced to calling rendezvous.__await__() and iterating that iterator.
What is actually going on
Async is cooperative multitasking. Only one piece of code runs at a time. Progress is achieved by explicitly yielding control. When one task yields, the event loop can resume some other task, and later come back to the first one. This isn’t about parallel CPU execution; it’s about pausing code that is waiting and letting other code run in the meantime.
This distinction matters because Python code that is purely CPU bound doesn’t benefit from jumping between tasks. If all you do is crunch numbers, splitting work across coroutines usually just adds context switching overhead. Async shines when the task is not CPU bound, such as network I/O or database queries. After issuing a request to an external system, Python has nothing useful to do inside that task until the response arrives, which is an eternity on the CPU timescale. In that idle time, other tasks can run.
How do you express this “pause here, resume later” in Python code? The primitive is yield. Generators provide the pause-and-resume semantics the event loop needs. That’s why the await protocol is specified in terms of an iterator: __await__ must return something the runtime can iterate, which in practice is driven by yield points.
Why __await__ returns an iterator
The iterator requirement exists because yielding is how Python code cooperatively gives up control. The event loop coordinates who gets to run and when to resume something that has yielded. In pure Python, yield is the mechanism to suspend execution and later continue from the same point. The generator/iterator protocol is therefore the natural and available representation for “awaitable progress.”
There’s also a practical boundary. Most of the real async primitives you interact with—from network sockets to database drivers—are implemented in lower-level C libraries. You don’t write those primitives in pure Python; you use them. The asyncio module exposes some of these primitives so you can orchestrate them. If you do need Python-level awaitable behavior, __await__ is the hook that lets you integrate with the event loop using the only pause-capable construct Python provides: yield.
Under the hood: how iteration drives await
When the interpreter executes await obj, it obtains an iterator by calling obj.__await__(). Advancing that iterator lets the awaitable yield control. When control is yielded, the event loop can run something else. Later, when it’s time to resume, iteration continues from the point of the last yield, eventually producing a result by finishing the iterator. The generator-based iterator embodies the pause/resume lifecycle the event loop needs.
Could await accept “any” object?
Within Python’s semantics, you need a way to suspend and resume code. Yield is the construct that does that, and generators expose yield through the iterator protocol. That’s why await is defined in terms of __await__ returning an iterator, rather than accepting arbitrary objects without a way to yield.
A practical pattern: Python-level wrapper awaitables
Most awaitables you use will come from lower-level C modules. If you want to expose async-like behavior in Python and adapt results before returning them to callers, __await__ is how you do it. The pause points must be expressed with yield because that’s how you let other work run while you wait.
async for record in low_level_stream:
    yield Wrapped(record)
This shows the intent: you await a lower-level async result and yield adapted values. The important takeaway is the same: yielding is what pauses your code and lets the event loop interleave work.
Why this matters
Understanding that async in Python is cooperative changes how you design and diagnose systems. If code never yields, nothing else runs. If work is CPU bound, switching between coroutines doesn’t make it complete faster. And if you need Python-level awaitables, __await__ plus yield is the integration point with the event loop.
Takeaways
Await targets awaitables that expose __await__ returning an iterator because generators and their yield statements are the way Python code can pause and resume. The event loop uses that iterator to coordinate execution. Most real async primitives live in C, and asyncio exposes some of them so you can orchestrate I/O and other non-CPU-bound operations efficiently. When you need to shape or adapt async results in Python, __await__ is the hook, and yield is the mechanism that makes cooperative multitasking possible.
The article is based on a question from StackOverflow by Meet Patel and an answer by deceze.