async and await Explained Simply

One cook who never stands still

Advanced 14 min

In this lesson

A thread pool hires more assistants. async does something different and stranger: it keeps one worker, and makes that worker refuse to ever stand idle. Whenever it would have to wait, it puts the job down, picks up another, and comes back later.

This is the style modern Python web frameworks are built on, and it is worth being able to read even if you never write a whole async program yourself. We stay at the basics: enough to follow the code and know when it applies.

Explain it like I’m 5

await is the cook saying “this needs ten minutes in the oven — I will chop the onions and come back to it.” One cook, no idle standing, dinner much sooner.

A coroutine is a function you can pause

Put async in front of def and the function changes character. Calling it no longer runs it. Instead you get a coroutine, the function packaged up, ready to go, waiting for something to actually start it.

This trips up everyone exactly once, and it is worth seeing rather than being told. A call that looks completely normal comes back with an object instead of your answer.

See it for yourself. Calling fetch() gives you a coroutine, not the string. Something has to drive that coroutine to get the value out, and driving it means sending it a None to start it, at which point it runs to its return and signals that it is finished by raising StopIteration, with the return value tucked inside. Fill in the try block to catch that and pull the value out.

import asyncio

async def fetch(name):
    return f"data from {name}"

coro = fetch("api")               # this does NOT run the function
print("type:", type(coro).__name__)
print("is coroutine:", asyncio.iscoroutine(coro))

value = None
# TODO: start the coroutine, and catch StopIteration to read its return value

print("value:", value)

What you actually write instead

You will never call send(None) in real code. That was the mechanism laid bare, and it is worth having seen once because it shows there is no magic: a coroutine is a resumable function, and StopIteration carrying a return value is the same protocol you met in Unit 10's iterators.

In real code an event loop does the driving. asyncio.run() starts one, runs your coroutine to completion, and shuts it down. Inside async code, await is how you say “drive this one and give me its result.”

Example
import asyncio, time

async def fetch_page(number):
    await asyncio.sleep(0.2)        # waiting, but not blocking
    return f"page {number}"

async def main():
    start = time.perf_counter()

    pages = await asyncio.gather(*(fetch_page(n) for n in range(10)))

    print(f"fetched {len(pages)} pages in {time.perf_counter() - start:.1f}s")
    print(pages[:3])

asyncio.run(main())
Output
fetched 10 pages in 0.2s
['page 0', 'page 1', 'page 2']
Ten pages in the time of one. No extra threads involved.

The two mistakes that break every first async program

Forgetting await. You get a coroutine object where you expected a value, and the code that follows fails oddly, or worse, succeeds while doing nothing, because the function you thought you called never ran. Python does warn you, with RuntimeWarning: coroutine 'fetch' was never awaited. Read that warning; it is naming your bug exactly.

Calling a blocking function inside a coroutine. This one is nastier because nothing warns you at all. One cook only works if the cook never freezes. A regular time.sleep(), or the requests library from Unit 7, stops the entire event loop dead. Every other coroutine waits, and your async program becomes a slow sequential program with extra syntax.

Example
import asyncio, time

async def fetch_page(number):
    time.sleep(0.2)                 # WRONG: blocks the whole event loop
    return f"page {number}"

async def main():
    start = time.perf_counter()
    pages = await asyncio.gather(*(fetch_page(n) for n in range(10)))
    print(f"fetched {len(pages)} pages in {time.perf_counter() - start:.1f}s")

asyncio.run(main())
Output
fetched 10 pages in 2.0s
One character of difference from the fast version. Ten times slower.

So which do you reach for?

For most scripts, the thread pool. It is four lines, it works with libraries you already use including requests, and you do not have to make anything else async.

Reach for async when you are working in a framework that is already async, or when you need very many concurrent operations: thousands of connections, where one thread each would be far too much memory. Async scales further; threads are easier to retrofit.

The trap to avoid is half-converting. Async is contagious: to await something, the caller must be async too, all the way up to asyncio.run(). A single blocking call anywhere in that chain undoes the benefit for everyone.

Common mistake: Forgetting await and printing a coroutine object

Why it happens:

The call looks exactly like a normal function call, because it is one. It just returns something unexpected.

How to fix it:

Put await in front. If Python says coroutine ... was never awaited, it has found the line for you.

Common mistake: Calling requests or time.sleep inside a coroutine

Why it happens:

It runs without error, so nothing suggests a problem.

How to fix it:

Blocking calls freeze the whole loop. Use await asyncio.sleep() and an async HTTP client such as httpx.

Common mistake: Using async for CPU-bound work

Why it happens:

Async is the newest tool, so it feels like the most powerful one.

How to fix it:

Async only helps at await points, and a calculation has none. Use processes for that.

Common mistake: Calling asyncio.run inside code that is already async

Why it happens:

It is how the program started, so it looks like the way to run any coroutine.

How to fix it:

asyncio.run() starts a loop and refuses to start a second one. Once you are inside async code, use await.

What does await do?

What does asyncio.gather give you?

What happens if you call an async function without awaiting it?

Mini exercise (medium)

Four expressions, each involving the coroutine function get_name(). For each, say what the value ends up being: "the string", "a coroutine", or "a list of coroutines". Assume the ones using await sit inside an async def.

Your turn. Fill in the code below and press Run to test it right here, nothing to install.

import asyncio

async def get_name():
    return "Ada"

# What is each expression's value? Use exactly one of:
#   "the string"   "a coroutine"   "a list of coroutines"
# Assume the ones using await sit inside an async def.
VERDICTS = {
    "get_name()": "?",
    "await get_name()": "?",
    "asyncio.run(get_name())": "?",
    "[get_name() for _ in range(3)]": "?",
}

for expression, verdict in VERDICTS.items():
    print(f"{expression} -> {verdict}")

What to learn next

You met the coroutine, a function that returns an object instead of a value until something drives it, and drove one by hand to see that StopIteration is carrying the return value, exactly as in Unit 10. Then asyncio.run, await, and gather for the real thing, plus the two mistakes: the forgotten await, and the blocking call that quietly undoes the whole benefit.

One promise still outstanding. Threads did nothing for calculation, and you were asked to take that on trust. The GIL, Processes, and Shared State pays it off, and shows the bug that never crashes.