Definition
A program that talks to the network spends most of its time waiting. It sends a request, then sits idle for a full second, while ten other requests could have gone out. That is the problem to solve: pausing one piece of work without stopping everything else with it.
A coroutine is the Python answer. It is a function able to pause mid-execution, hand control back to the rest of the program, then resume where it stopped, with its variables intact. It is declared by putting async in front of def, and every place where it agrees to pause carries an await.
Here is its most common shape, a piece of work that spends nearly all its time waiting.
import asyncio
async def download(url):
print("start", url)
await asyncio.sleep(1) # the only place the function hands control back
print("done", url)
return url.upper()The word comes from "cooperative", and it should be taken literally: a coroutine is never interrupted by force, it picks its own pause points. So you can see by reading the only places where it may be suspended, which spares you from guarding your shared variables. The threads of threading, on the other hand, can be cut anywhere.
Calling it does not run it
This is the first-day surprise. Writing download("...") triggers nothing: the call builds a coroutine object, an execution prepared but not started, and hands it straight back. An ordinary function does its work at call time, this one merely wraps the work up.
task = download("https://example.com")
print(task)
# <coroutine object download at 0x104f...>
# nothing printed: the body never began
# RuntimeWarning: coroutine 'download' was never awaitedThat leaves the question of who starts that work, and there are only two answers: an await from another coroutine, or a trip through asyncio, with asyncio.run() as the entry point. The first coroutine of a program cannot be awaited by anyone, since no other one is running yet: asyncio.run() starts the event loop and hands it that work.
Forgetting an await does not crash the program. Python reports never awaited when the object is collected, often long after the offending line. The code carries on, the work never happened, and it shows up elsewhere: an unexpected None, an empty file.
Function, generator, coroutine
These three are declared in almost the same way, which makes them easy to mix up. Everything is decided at call time, so read the table starting from its second row.
| Criterion | Function | Generator | Coroutine |
|---|---|---|---|
| Declaration | def | def with yield | async def |
| What the call hands back | The result | A generator | A coroutine object |
| Pause point | None | yield | await |
| Who restarts it | Nobody | next() or a loop | The event loop |
| Final value | Its return | Rarely read | The awaited result |
The kinship with the generator is no coincidence: coroutines grew out of generator machinery, and both keep their state across a pause. What separates them is who decides the restart. A generator moves on when your code asks for the next value, a coroutine when an outside result arrives.
Where they actually save time
Plenty of people switch their functions to async def, restart the program and find it just as slow as before. Nothing is wrong: a single coroutine speeds up nothing, it only makes the pause negotiable. The gain comes when several waits overlap.
The two versions below call the same coroutine on the same list, and take very different amounts of time.
# Three seconds: the waits follow each other
for url in urls:
await download(url)
# One second: the waits overlap
await asyncio.gather(*(download(url) for url in urls))The first one is the most widespread misreading: await means "stop here until this wait is over", so inside a loop it builds a queue. gather does the opposite and hands the coroutines to the loop in one go, then returns the results in argument order, never in arrival order.
The blocking code trap
A coroutine only yields control at its await points, nowhere else. A blocking call slipped in the middle freezes the whole program, including tasks that have nothing to do with it: the loop is waiting for it to finish too.
This is the most puzzling failure in asynchronous code, because it does not look like one: the code is correct, it raises no exception, and everything runs as slowly as before. Of the two functions below, only one deserves its async def.
async def bad():
time.sleep(2) # freezes every task
async def good():
await asyncio.sleep(2) # yields control for two secondsInside a coroutine, anything that waits must wait with await. A classic network library such as requests, a plain file read or a long computation ignore that protocol: they need an asynchronous counterpart, or a separate thread through asyncio.to_thread().
Frequently asked questions
Should a whole codebase be moved to async?
No, and it is rarely even a good idea. Asynchronous code pays off in programs that spend their time waiting on a network, a database or a disk. On a computation that keeps the processor busy it brings no gain at all, since there is no wait to overlap. Look first at where your program really loses its time.
Can await be written inside a normal function?
No, that raises a SyntaxError while the file is being read, before anything runs. The word marks a pause point, and an ordinary function has none: there is nobody to hand control back to. From synchronous code, the coroutine is started by asyncio.run().
Can the same coroutine object be awaited twice?
No, it is used up like an iterator: the second await raises RuntimeError: cannot reuse already awaited coroutine. To redo the work, call the function again, since it builds a fresh object. The Python course walks through that cycle, from the object created to the result collected.