What does calling an `async def` function without `await` actually return?
answer
- Calling it is not running it
- The call produces an object
- Body waits for something to drive it
- Warning fires when it is collected
- was never awaited
basics
~20 sCalling 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 sAn `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 linesimport 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
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.
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.
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.
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