skip to content

Why did async def and await replace generator-based coroutines in Python?

level: seniorimportance: should knowfreq 38%

answer

  1. One object type meant two different things
  2. Convention instead of compiler checks
  3. Suspension needed its own identity
  4. async with and async for were unsayable
  5. types.coroutine still bridges to the loop

basics

~10 s

Because a generator-based coroutine was indistinguishable from an ordinary generator: same type, same interface, no compiler checks. Native coroutines added in 3.5 gave suspension its own type, syntax and error messages.

solid answer

~40 s

Before 3.5, a coroutine *was* a generator, marked only by a decorator convention, so nothing could tell a lazy data producer from something meant to suspend on I/O. Iterating a coroutine with `for` was legal and wrong, delegation was spelled `yield from` like ordinary iteration, and asynchronous iteration and context managers were inexpressible. PEP 492 gave suspension a first-class identity in 3.5: `async def` produces a distinct type that is not an iterator, `await` is a construct the compiler only allows inside it, and `async with` and `async for` became sayable. Diagnostics improved too - an un-awaited coroutine warns. `types.coroutine` survives in 3.14 as the low-level bridge, marking a generator function so `await` accepts its objects, since a bare `yield` inside `async def` would make an async generator instead.

code

python · 12 lines
python
import asyncio
import types

@types.coroutine
def suspend_once():
    yield
    return "resumed by the event loop"

async def main():
    print(await suspend_once())

asyncio.run(main())

go deeper

for a junior

Recall the headline: coroutines used to be generators, and since 3.5 they are their own kind of object written with async def and await. You are not expected to have written the old style.

for a middle

Explain the concrete gains: a distinct type that is not an iterator, await as a construct the compiler only allows inside async def, async with and async for becoming expressible, and warnings for coroutines that were never awaited.

for a senior

Show you know where the seam still is. Name types.coroutine and await as the bridge to the event loop, note that asyncio dropped its own coroutine decorator in 3.11, and explain why a bare yield in async def makes an async generator instead.

for a principal

Frame it as the language choosing explicit identity over convention, and carry the lesson forward: when a mechanism is marked only by a decorator that nothing can enforce, mistakes stay invisible until runtime. That is the argument for retiring look-alike patterns in your own codebase.

### What a coroutine used to be Through Python 3.4 there was no coroutine object. A coroutine was an ordinary generator whose suspension points happened to mean 'I am waiting for something' rather than 'here is the next value', with a scheduler interpreting what came out of each `yield`. `yield from`, added in 3.3, made the style practical by letting one such generator delegate to another and receive its return value, and asyncio's original API leaned on exactly that machinery, marking coroutines with a decorator it supplied. That decorator was deprecated in 3.8 and removed in 3.11; generator-based coroutines are not accepted by modern asyncio. ### The problems that forced a change **No type distinction.** A coroutine and a lazy data generator were the same object type with the same methods. Nothing - not the reader, not the compiler, not the library - could tell them apart except a decorator convention. Passing a coroutine to something that iterates it was legal and silently wrong. **No syntactic distinction.** Suspension was spelled `yield from`, the same words used for plain iteration delegation. Whether a line meant 'await this I/O' or 'forward these items' depended entirely on context the compiler could not see. **Nothing could be checked.** Because the semantics lived in convention, mistakes surfaced as strange runtime behaviour rather than errors. There was no mechanism to say 'you may only suspend inside a coroutine' and no reliable way to warn that a coroutine had been created and never run. **Whole constructs were inexpressible.** An asynchronous context manager and an asynchronous iterator have no spelling in the generator world; there is nowhere to put the suspension in `with` or `for` when the only tool is `yield from` inside a generator body. ### What PEP 492 changed in 3.5 `async def` compiles to a different kind of object. It is a native coroutine - its own runtime type, registered under the abstract Coroutine interface, and deliberately *not* an iterator, so a `for` loop over one fails instead of doing something surprising. `await` is a grammar production the compiler accepts only inside an `async def` body, which turns a whole class of mistakes into syntax errors. It operates on awaitables - objects implementing `__await__` - so libraries can define their own without pretending to be generators. And `async with` and `async for` became sayable, which is why asynchronous connection pools and streaming clients read like ordinary code today. The ergonomics followed. A coroutine that is created and never awaited warns rather than vanishing silently. `inspect.iscoroutinefunction` gives a straight answer about a function, which the decorator convention never could. Reading a body, `await` marks every point where control can leave, so the places another task might interleave are visible on the page - the single biggest readability gain of the change. ### What survives: types.coroutine `types.coroutine` is still in 3.14 and is not deprecated. Applied to a generator function, it marks the code object with the iterable-coroutine flag - the same bit exposed as `inspect.CO_ITERABLE_COROUTINE` - so `await` accepts the generator objects that function returns. Without that mark, awaiting a plain generator raises `TypeError: 'generator' object can't be awaited`. This matters because `async def` bodies cannot `yield` to the scheduler: a `yield` inside `async def` makes the function an async *generator*, a different thing entirely. So the bottom of every async stack - the point where control genuinely hands back to the event loop - has to be either an object with `__await__` or a generator marked this way. It is a plumbing tool for people writing event loops and low-level primitives, not something application code should reach for. A sharp detail worth knowing: `inspect.iscoroutinefunction` still reports `False` for a function decorated this way, because it is a generator function that `await` tolerates, not a native coroutine function. ### What to say Lead with identity: the old style overloaded generators to mean two unrelated things, and the language gave suspension its own type, its own syntax and its own diagnostics. Then show you know where the seam still is - `types.coroutine` and `__await__` are how the modern machinery reaches the loop, which is why the generator story is worth understanding even though nobody should write coroutines that way now.

  • What happens on 3.14 if you await a plain generator object?
    It raises `TypeError: 'generator' object can't be awaited`. `await` accepts native coroutines, objects implementing `__await__`, and generators whose code object carries the iterable-coroutine flag. A plain generator has none of those, so the interpreter refuses rather than iterating it - which is precisely the distinction the old generator-based style could not make.
  • Why can't you just write yield inside an async def to suspend to the event loop?
    Because `yield` inside `async def` changes what the function *is*: it becomes an asynchronous generator, consumed with `async for`, not a coroutine that hands control to the loop. The suspension seam has to be an awaitable - an object with `__await__`, or a generator function marked with `types.coroutine`. That is how the low-level primitives in an event loop implementation reach the scheduler.
  • Is types.coroutine deprecated now that native coroutines exist?
    No. It is still present in 3.14 and still the supported way to mark a generator function so `await` accepts its objects. What was removed is asyncio's own coroutine decorator, dropped in 3.11 after deprecation in 3.8. The distinction matters: application code should never need `types.coroutine`, but event-loop and primitive authors do.

saying these in an interview costs you the question

  • Says async def is only syntactic sugar over generators with no semantic difference
  • Thinks a native coroutine can be iterated with a for loop
  • Believes asyncio still accepts generator-based coroutines
  • Claims types.coroutine was removed along with the old decorator
  • Says await works on any generator object
  • Thinks a bare yield inside async def suspends to the event loop

context