On this page
Description
A backend spends most of its time waiting: for the database, for another service, for a client to send the rest of a request. Python's asyncio lets one thread juggle thousands of those waits at once, by switching to other work whenever the current task is waiting. Web frameworks like FastAPI are built on it, so even if you never write an event loop yourself, you'll write async def and await every day.
async code looks almost like ordinary code, and that's both its charm and its trap. Here's an endpoint that fetches three things for a dashboard:
async def dashboard(user_id):
user = await get_user(user_id) # 200 ms
orders = await get_orders(user_id) # 200 ms
recs = await get_recommendations(user_id) # 200 ms
return render(user, orders, recs)
It's async, so the team expected the three calls to overlap. They don't: the endpoint takes 600 ms, exactly as long as plain blocking code would. By the end of the lesson you'll know why, and how to get it down to 200 ms with one line.
We'll go from simple to more technical:
- Coroutines: what
async defandawaitactually do. - The event loop: one thread, many waiting tasks.
- Running things at the same time with
gatherand tasks. - Keeping tasks alive, and starting the loop with
asyncio.run.
Coroutines
A function defined with async def is a coroutine function. Calling it doesn't run it - it gives you a coroutine object, a paused piece of work:
async def send_email(to: str) -> None:
await smtp.send(to, ...)
send_email("a@example.com") # creates a coroutine object - sends nothing
await send_email("a@example.com") # runs it to the end
Forgetting the await is the classic asyncio bug: the email silently never goes out. Python does leave a clue when the forgotten coroutine is thrown away - RuntimeWarning: coroutine 'send_email' was never awaited - so watch your logs for it.
await means "run this and wait for its result before going on". While it waits - for a network reply, a timer, a lock - the current coroutine is paused, and that's the moment other coroutines get to run. You can only write await inside an async def function.
The event loop
Something has to decide which paused coroutine runs next. That's the event loop. It runs on one thread and keeps a list of coroutines that are ready to continue. Each one runs until it reaches an await on something that isn't ready yet; then the loop moves on to the next ready one, and comes back when the thing it was waiting for is done.
Two rules follow:
- It's concurrency, not parallelism. Thousands of coroutines can be waiting at once, but only one runs Python code at any moment. A coroutine that does heavy computing, or calls a blocking function like
time.sleeporrequests.get, stops every other coroutine until it's done (the asyncio pitfalls skill covers the fixes). - Coroutines only switch at
await. Two workers that each record "start",await asyncio.sleep(0), then record "end" interleave asA start, B start, A end, B end. The code between two awaits always runs without interruption from other coroutines - unlike threads, which can be switched anywhere.
Running things at the same time
Back to the dashboard. Each await waits for its call to finish before the next line starts, so the three calls run one after another: 200 + 200 + 200 ms. The event loop could serve other requests during those waits, but this request still does its steps in order.
To overlap them, start all three and wait for all of them together:
user, orders, recs = await asyncio.gather(
get_user(user_id), get_orders(user_id), get_recommendations(user_id))
Measured with three 0.2 s calls: one after another took 0.6 s; with gather, 0.2 s.
asyncio.gather(...)runs its arguments concurrently and returns the results in argument order, whatever order they finish in: tasks taking 0.3, 0.1 and 0.2 s returned['slow', 'fast', 'mid']. (What happens when one of them fails is the next skill's topic.)asyncio.create_task(coro)starts a coroutine running "in the background" as a Task, and gives you the task toawaitlater. It starts running as soon as the current coroutine reaches its nextawait:
user_task = asyncio.create_task(get_user(user_id)) # starts now
orders_task = asyncio.create_task(get_orders(user_id)) # starts now
await render_header() # runs while both calls are in flight
user = await user_task
orders = await orders_task
Keeping tasks alive, and starting the loop
Keep a reference to every task you create. The event loop only holds a weak reference to tasks - one that doesn't keep the task alive. A fire-and-forget asyncio.create_task(audit(event)) whose result you don't store can be garbage-collected before it finishes, and the audit silently never happens. The asyncio documentation warns about exactly this. Keep tasks in a set and remove them when they're done - or, better, use a TaskGroup (next skill), which keeps them for you:
background = set()
task = asyncio.create_task(audit(event))
background.add(task)
task.add_done_callback(background.discard)
Start the program with asyncio.run. A script's entry point is asyncio.run(main()): it creates an event loop, runs main() to the end, and closes the loop. Call it once, at the top. It refuses to start inside a loop that's already running - RuntimeError: asyncio.run() cannot be called from a running event loop - a common sight in Jupyter notebooks (which run a loop already; just await there) and in code that mixes sync and async layers.
Which tool for which job?
| You want | Use |
|---|---|
| Run one coroutine and use its result | await coro() |
| Run several and get all results | await asyncio.gather(...) |
| Start something now, collect it later | asyncio.create_task(...), keep the task |
| Start the whole program | asyncio.run(main()), once |
| Pause briefly without blocking others | await asyncio.sleep(seconds) |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Calling a coroutine without await | nothing runs, plus a RuntimeWarning | await it, or make it a task |
| Awaiting independent calls one by one | their times add up | gather, or create tasks first |
| A task with no reference | may be garbage-collected mid-run | keep it in a set, or use a TaskGroup |
asyncio.run inside async code | RuntimeError | just await |
time.sleep or heavy computing in a coroutine | every other coroutine stalls | await asyncio.sleep, or move the work off the loop |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Why does the dashboard take 600 ms, and how do you make it about 200 ms?
Show answer
Each await waits for its call to finish before the next starts, so the three 200 ms calls add up. Run them together with await asyncio.gather(get_user(...), get_orders(...), get_recommendations(...)).
2. What does send_email(user) do inside an async def, without await?
Show answer
Nothing runs. It creates a coroutine object that's never awaited, and Python warns coroutine 'send_email' was never awaited when it's thrown away.
3. Why should you keep a reference to a task you create but don't await?
Show answer
The event loop keeps only a weak reference to tasks, so a task nothing else refers to can be garbage-collected before it finishes.
Key takeaways
- Calling an
async deffunction creates a coroutine; it runs only when awaited or made into a task. - One thread, one event loop: coroutines take turns at
awaitpoints - concurrency, not parallelism. - Awaits in sequence add up;
gatherand tasks overlap.gatherreturns results in argument order. - Keep a reference to every task; call
asyncio.runonce, at the entry point.