async/await and coroutines
The syntax, compared to what you know
If you’ve used JavaScript, this will look immediately familiar — Python’s
async/await is deliberately similar in spelling and purpose. Go
(goroutines) and Java (CompletableFuture) solve the same underlying
problem — running many I/O-bound operations without blocking each other —
with different syntax and a different underlying model; Python’s is
closest to JS’s.
async def marks a function as a coroutine function — a function
that can be paused and resumed, rather than running start-to-finish in
one uninterrupted block the way every function has so far. await
inside it marks a specific point where it’s willing to pause: “wait here
for asyncio.sleep(2) to finish, and let something else run in the
meantime, before continuing past this line.”
Note the return value wasn’t printed above — asyncio.run(...) runs the
coroutine but this example didn’t do anything with what it returned.
asyncio.run() is the entry point: the thing that actually starts
Python’s event loop and runs a coroutine to completion. You’ll almost
always see exactly one asyncio.run() call, at the very top level of a
program — everything else happens through await, inside other
coroutines.
The gotcha: calling a coroutine function doesn't run it
This is worth seeing directly, since it’s a genuinely common mistake:
calling an async def function the normal way — without await — does
not execute its body. It returns a coroutine object instead, a
paused, not-yet-started task description:
Notice "calling search API..." never printed — the function body never
actually ran. call_search_api() on its own just creates a coroutine
object; it doesn’t start it. Getting it to actually run requires either
await-ing it (from inside another coroutine) or handing it to
asyncio.run() (from regular, non-async code) — one of the two has to
actually drive it forward:
If you ever see a warning in the wild like RuntimeWarning: coroutine '...' was never awaited, this is exactly what happened — a coroutine
object got created and then simply discarded, its body never actually
executing.
await-ing from inside another coroutine
The more common shape than a single top-level asyncio.run() call:
one coroutine awaits another, chaining them together, with only the
outermost one actually passed to asyncio.run():
main() itself is a coroutine function too — async def all the way
through. await call_search_api() inside it is what actually runs
call_search_api’s body and waits for its result, exactly the same
mechanism as before, just one level deeper. This nested-await shape —
one async def function calling await on another — is how real
async code is actually structured; a single, flat asyncio.run() call
on one coroutine, like the earlier examples, is really just the smallest
possible version of that same pattern.
Nothing here is concurrent yet, though — await call_search_api() still
waits for it to fully finish before main() moves on to its next line,
same as a regular function call would. Making multiple await-able
operations actually overlap is exactly what the next section,
asyncio.gather(), is for.
What does async def mark a function as?