A coroutine in Python: the function that pauses and resumes

A coroutine pauses mid-execution and resumes right where it stopped. Why calling one is not enough to actually run it.
5 min read
Believemy logo

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.

PYTHON
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.

PYTHON
task = download("https://example.com")
print(task)
# <coroutine object download at 0x104f...>
# nothing printed: the body never began

# RuntimeWarning: coroutine 'download' was never awaited

That 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.

Warning

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.

CriterionFunctionGeneratorCoroutine
Declarationdefdef with yieldasync def
What the call hands backThe resultA generatorA coroutine object
Pause pointNoneyieldawait
Who restarts itNobodynext() or a loopThe event loop
Final valueIts returnRarely readThe 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.

PYTHON
# 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.

PYTHON
async def bad():
    time.sleep(2)           # freezes every task

async def good():
    await asyncio.sleep(2)  # yields control for two seconds

Inside 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

Question

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.

Question

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

Question

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.

Related terms

Discover our python glossary

Browse the terms and definitions most commonly used in development with Python.

Share this article

Want to help us? Share this article on your networks or even better: on your site, in an article or in your newsletter.