async and await Explained Simply
One cook who never stands still
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)
try: then coro.send(None), then except StopIteration as finished: and set value = finished.value. The send(None) line always raises here, because this coroutine reaches its return without ever pausing.
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
try:
coro.send(None)
except StopIteration as finished:
value = finished.value
print("value:", value)
type: coroutine
is coroutine: True
value: data from api
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.”
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())
fetched 10 pages in 0.2s ['page 0', 'page 1', 'page 2']
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.
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())
fetched 10 pages in 2.0s
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
The call looks exactly like a normal function call, because it is one. It just returns something unexpected.
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
It runs without error, so nothing suggests a problem.
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
Async is the newest tool, so it feels like the most powerful one.
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
It is how the program started, so it looks like the way to run any coroutine.
asyncio.run() starts a loop and refuses to start a second one. Once you are inside async code, use await.
What does await do?
await marks a pause point. The single worker goes and does something else, then comes back.
What does asyncio.gather give you?
gather runs them all concurrently but keeps the results lined up with your inputs.
What happens if you call an async function without awaiting it?
You get the coroutine, the work never happens, and Python emits a RuntimeWarning naming the function.
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}")
The rule is mechanical: calling a coroutine function produces a coroutine, and only await or asyncio.run() turns one into a real value. A comprehension calls the function repeatedly without awaiting anything.
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()": "a coroutine",
"await get_name()": "the string",
"asyncio.run(get_name())": "the string",
"[get_name() for _ in range(3)]": "a list of coroutines",
}
for expression, verdict in VERDICTS.items():
print(f"{expression} -> {verdict}")
get_name() -> a coroutine
await get_name() -> the string
asyncio.run(get_name()) -> the string
[get_name() for _ in range(3)] -> a list of coroutines
assert VERDICTS["get_name()"] == "a coroutine", "calling a coroutine function does not run it"
assert VERDICTS["await get_name()"] == "the string", "await drives the coroutine and gives you the value"
assert VERDICTS["asyncio.run(get_name())"] == "the string", "asyncio.run drives it too, from outside async code"
assert VERDICTS["[get_name() for _ in range(3)]"] == "a list of coroutines", "the comprehension never awaits anything"
print("✓ Looks good!")