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.

Try it — edit and run

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:

Try it — edit and run

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:

Try it — edit and run

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():

Try it — edit and 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.

Check your understanding
1/5

What does async def mark a function as?