skip to content

Coroutines and await Semantics

An async def call returns a coroutine object that does nothing until awaited or scheduled, and await marks a suspension point. The classic probe: why calling it without await warns instead of working.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What does calling an `async def` function without `await` actually return?

level: juniorimportance: must knowfreq 85%

answer

  1. Calling it is not running it
  2. The call produces an object
  3. Body waits for something to drive it
  4. Warning fires when it is collected
  5. was never awaited

basics

~20 s

Calling an async def function returns a coroutine object and runs none of its body. The body executes only when something drives it: an await, or asyncio.run. If nothing ever does, Python emits a RuntimeWarning saying the coroutine was never awaited.

solid answer

~40 s

An `async def` function is a coroutine *function*; calling it builds a coroutine *object* and executes zero lines of the body. The object is a suspendable, single-use frame that needs a driver: `await c` from inside another coroutine, or `asyncio.run(c)` at the top level, or being handed to the event loop as a task. Until then it is inert. If the object is garbage-collected without ever having been started, CPython's finalizer prints `RuntimeWarning: coroutine 'name' was never awaited` - almost always a missing `await`, or async code called from a synchronous function that cannot await. Awaiting the same coroutine object twice fails with a `RuntimeError`, because the frame is consumed once it finishes.

code

python · 12 lines
python
import asyncio

async def fetch_flag(name):
    await asyncio.sleep(0.1)
    return name, True

async def main():
    c = fetch_flag("dark-mode")   # no body has run yet
    print(type(c).__name__)       # coroutine
    print(await c)                # now it runs

asyncio.run(main())

go deeper

for a junior

Recall the one-line rule: calling an async def function gives you a coroutine object and runs nothing. Be able to name the warning text and say the fix is a missing await.

for a middle

Explain the mechanics: a coroutine object is a suspendable frame driven by send, finished via StopIteration, single use, and warned about from its finalizer. Distinguish coroutine function from coroutine object out loud.

for a senior

Show how this bug reaches production: a warning rather than an error means the work silently never happens. Talk about surfacing it with development mode, turning that warning into an error in CI, and where forgotten awaits typically hide.

for a principal

Own the design tradeoff: separating creation from execution is what makes every suspension point visible in the source, at the price of a call that performs no work. Decide how a codebase enforces the discipline - warning filters, type checking, and review rules.

### The call and the body are two separate events For an ordinary function, `f()` means "run the body now and hand me the result". For a function defined with `async def`, the call means something quite different: CPython creates a **coroutine object** wrapping a fresh, not-yet-started frame and returns it immediately. Not one statement of the body has executed. That single fact explains most of the confusion beginners have with asyncio. ```pycon >>> import asyncio, inspect >>> async def ping(): ... print("running") ... return "pong" ... >>> c = ping() # nothing printed >>> inspect.iscoroutine(c) True >>> asyncio.run(c) running 'pong' ``` The vocabulary matters in an interview. `ping` is the coroutine *function* (`inspect.iscoroutinefunction(ping)` is `True`); `c` is the coroutine *object* (`inspect.iscoroutine(c)` is `True`). Its type is `types.CoroutineType`, and it is registered with `collections.abc.Coroutine`, which itself extends `collections.abc.Awaitable`. ### What actually runs the body A coroutine object is a *driveable* frame, not a scheduled unit of work. Something has to push it forward: * `await c` inside another coroutine - the awaiting coroutine hands control down into `c` and resumes when it finishes. * `asyncio.run(c)` - starts an event loop, wraps the coroutine and drives it to completion. * Handing it to the running loop as a task, which is the topic of scheduling and belongs to its own subject. At the lowest level, a coroutine is driven exactly like a generator: `c.send(None)` advances it to its next suspension point, and when it finishes it raises `StopIteration` whose `value` is the return value. `asyncio` never asks you to do that by hand, but knowing it demystifies both the machinery and the error messages. ### The never-awaited warning If a coroutine object is finalized without ever having been started, CPython warns: ``` RuntimeWarning: coroutine 'refresh' was never awaited ``` Three things about it are worth knowing. First, it is a **warning, not an exception**: the program keeps running, and the work silently never happens - which is why this bug reaches production. Second, it fires from the object's finalizer, so it appears whenever the object is collected; with plain refcounting that is usually right away, but a reference held in a container or a cycle can push it to interpreter shutdown, far from the call site. Third, running under Python's development mode (`-X dev`) surfaces warnings that are otherwise filtered, and `sys.set_coroutine_origin_tracking_depth(n)` makes CPython record where the coroutine was created so the warning can point at the guilty line. The two common causes are a forgotten `await` inside an async function, and calling an async function from ordinary synchronous code, where there is no `await` available at all. The second one is the real design constraint: sync callers cannot consume coroutines, so the only bridge is a top-level `asyncio.run` or an already-running loop. ### Coroutine objects are single use Once a coroutine has run to completion its frame is exhausted. Awaiting it again raises `RuntimeError: cannot reuse already awaited coroutine`. If you need to run the same work twice, call the coroutine function twice to get two objects. This trips people who try to build a "retryable" value by storing one coroutine object and awaiting it in a loop; store the *function* and its arguments instead, and call it each attempt. A related tidy-up: if you deliberately want to discard a coroutine you created but will not run, `c.close()` finalizes it quietly and suppresses the warning. That is rare and is usually a smell - the honest fix is not to create it. ### Bridging from synchronous code Because `await` is only legal inside a coroutine function, a synchronous caller has no way to consume a coroutine object at all. The supported bridge is `asyncio.run(c)`, which starts a loop, drives the coroutine to completion, tidies up and returns the result - and which must not be called while a loop is already running in that thread. Inside code that is already async, the answer is simply `await`. Recognising which of the two situations you are in is most of what separates a confident asyncio answer from a hopeful one, because the never-awaited warning is exactly what you get when a synchronous function calls an async one and quietly drops the object on the floor. ### Why the language was designed this way Separating creation from execution is what makes `await` a *marked* suspension point. Because building the coroutine is free and side-effect-free, the caller decides when and how the work runs, and every place a coroutine can pause is visible in the source as an `await`. The cost is exactly the failure mode above: an expression that looks like a call but performs no work, and a warning instead of an error when you forget the second half.

  • When exactly does the never-awaited RuntimeWarning appear, and why is it sometimes far from the bug?
    It is emitted by the coroutine object's finalizer, so it appears when that object is collected. Under plain reference counting that is usually immediately after the statement, but if the object is stashed in a list or caught in a reference cycle it can surface much later, even at interpreter shutdown. Running with `-X dev` unfilters warnings, and `sys.set_coroutine_origin_tracking_depth` records creation frames so the message can point back at the call site.
  • Can you await the same coroutine object twice?
    No. A coroutine object wraps one frame and is single use; once it has completed, awaiting it again raises `RuntimeError: cannot reuse already awaited coroutine`. To run the same work twice, call the coroutine function again to get a second object. This is why retry helpers take a callable plus arguments rather than a pre-built coroutine object.
  • Is it pointless to write `async def` for a body containing no await?
    Not pointless, but it changes the contract: the function still returns a coroutine object, so every caller must await it. That is useful when the function implements an async interface or may gain a suspension point later. It buys no concurrency by itself, and it means synchronous callers can no longer use it directly.

Calling an async def function is like filling in an order form: the form exists and is complete, but nothing is cooked until you hand it to the kitchen.

saying these in an interview costs you the question

  • Says calling an async def function starts running the body
  • Thinks the returned coroutine object is already scheduled
  • Believes the never-awaited warning stops the program
  • Claims await is needed only to get the return value
  • Thinks one coroutine object can be awaited repeatedly
  • Confuses the coroutine function with the coroutine object

context

open as a page

Why do two `await` calls written one after another run sequentially, not concurrently?

level: middleimportance: must knowfreq 70%

basics

~20 s

Await means wait here until this one awaitable finishes. It suspends the current coroutine so the loop can run other already-scheduled work, but the next line of this coroutine still runs only after the first await completes, so back-to-back awaits add up.

open as a page

How can shared state change across an `await` when asyncio runs on one thread?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Await points are the only places another coroutine can run, so anything between two awaits is atomic but anything read before an await may be stale after it. A check-then-act sequence split by an await is a race even without threads.

open as a page

What must an object implement for `await obj` to be legal in Python?

level: middleimportance: nice to knowfreq 25%

basics

~10 s

Await accepts any awaitable: a coroutine object, or any object whose await method returns an iterator. The value the iterator finally carries in StopIteration becomes the result of the await. Anything else raises TypeError.

open as a page