Coroutines, async / await

RoadmapsPython Backend

Scenario

You call `result = fetch_user(1)` and it returns instantly — but `result` isn't the user, it's a `<coroutine object>`. You `print(result)` and get a RuntimeWarning: 'coroutine was never awaited.' The function you wrote never actually ran.

Mental model

A regular function is a worker who does the whole job the moment you call them. A coroutine is a worker who, when called, just hands you a to-do card. Nothing happens until someone (the event loop) picks up that card and works it — pausing whenever it hits an `await`.

`async def` does not define a function that runs — it defines a coroutine factory. Calling it builds a coroutine object (a paused, resumable computation) and returns immediately without executing the body. `await` is the only thing that drives a coroutine forward: it runs it until the coroutine yields control (because it's waiting on I/O), then lets whoever is awaiting decide what to do while it waits. This is cooperative multitasking — tasks voluntarily pause at `await` points so others can run.

Deep dive

`async def` turns a function into a coroutine function. Calling it (`fetch_user(1)`) runs *none* of the body — it constructs and returns a coroutine object. This is the single most common beginner surprise: the code inside an `async def` does not execute until the coroutine is awaited or scheduled on the event loop.

Calling an `async def` builds a coroutine object; it does not run the body.

`await` does two jobs. It *drives* the awaited coroutine (runs its body), and it marks a *suspension point* — a spot where this coroutine is allowed to pause and hand control back so other work can proceed while it waits on something slow (a network reply, a DB row). You can only use `await` inside an `async def`.

`await` both runs a coroutine and marks where it may pause to yield control.

You can only `await` an *awaitable*: a coroutine, a Task, or a Future. Awaiting a plain value or a normal function call is a `TypeError`. In practice you `await` other `async def` calls (`await db.fetch()`) and library primitives (`await asyncio.sleep(1)`). To actually start the top-level coroutine from synchronous code, you hand it to `asyncio.run(main())`, which spins up an event loop, runs the coroutine to completion, and tears the loop down.

`await` needs an awaitable; `asyncio.run(main())` is the sync→async entry point.

Coroutines are the async evolution of generators. A generator uses `yield` to pause and hand a value back to its caller; a coroutine uses `await` to pause and hand control back to the event loop. Same underlying machinery (a resumable, stack-preserving frame) — different purpose: generators produce values lazily, coroutines wait on I/O without blocking.

Coroutines are generators repurposed: `await` pauses for I/O like `yield` pauses for a value.

Code examples

Calling vs. awaiting a coroutine

import asyncio

async def fetch_user(uid):
    # pretend this is a slow DB / network call
    await asyncio.sleep(1)
    return {"id": uid, "name": "Ada"}

# WRONG: this runs none of the body
c = fetch_user(1)
print(c)          # <coroutine object fetch_user at 0x...>
# RuntimeWarning: coroutine 'fetch_user' was never awaited

# RIGHT: drive it from an event loop
async def main():
    user = await fetch_user(1)   # now the body actually runs
    print(user)                  # {'id': 1, 'name': 'Ada'}

asyncio.run(main())

`fetch_user(1)` just builds a coroutine object — printing it shows exactly that, and the 'never awaited' warning tells you the body never executed. Only `await fetch_user(1)` inside a running loop drives it to produce a result.

await is a suspension point, not a blocking call

import asyncio, time

async def slow_task(name):
    print(f"{name}: start")
    await asyncio.sleep(1)   # pause HERE, let others run
    print(f"{name}: done")

async def main():
    start = time.perf_counter()
    # both run concurrently: total ~1s, not 2s
    await asyncio.gather(slow_task("A"), slow_task("B"))
    print(f"elapsed: {time.perf_counter() - start:.1f}s")

asyncio.run(main())
# A: start / B: start / A: done / B: done / elapsed: 1.0s

At each `await asyncio.sleep(1)`, the coroutine yields control instead of blocking the thread. While A waits, B gets to run — so two 1-second waits overlap and finish in ~1 second total. That overlap is the entire point of async.

Common mistakes

Recall questions

Questions & answers

A developer writes `data = fetch_data()` where `fetch_data` is an `async def`, then tries to use `data` as a dict and gets errors. What went wrong and how do they fix it?

Calling an `async def` returns a coroutine object, not the result — the body never ran. `data` is a coroutine, not a dict. The fix is `data = await fetch_data()` inside an `async def` (ultimately entered via `asyncio.run(...)`). The give-away is that `data` prints as `<coroutine object ...>` and Python emits a 'coroutine was never awaited' warning.

Approach: Identify that `async def` returns a coroutine object, that awaiting is what runs it, and name the 'never awaited' warning as the diagnostic.

Explain the difference between `await coro()` and `asyncio.gather(coro(), coro())`. When would each be correct?

`await coro()` runs one coroutine and blocks the current coroutine until it finishes — sequential. `asyncio.gather(...)` schedules multiple coroutines to run concurrently and waits for all of them, so their I/O waits overlap. Use a single `await` when a step depends on the previous result; use `gather` when independent I/O operations can proceed in parallel (e.g. fetching three unrelated endpoints).

Approach: Contrast sequential vs concurrent execution, tie the choice to whether the operations are independent, and note that `await` alone gives no speedup for parallelizable I/O.

Continue learning

Next: The event loop

Next: Tasks & gather

Next: Blocking Calls in Async Code

Return to Python Backend Roadmap