Python · Python asyncio

Python: Coroutines, Tasks and the Event Loop

What calling an async function really does, why sequential awaits don't overlap, gather and create_task, where coroutines switch on the event loop's one thread, and why tasks need a reference.

  • 6 min read
  • 11 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Coroutines
  3. The event loop
  4. Running things at the same time
  5. Keeping tasks alive, and starting the loop
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

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:

  1. Coroutines: what async def and await actually do.
  2. The event loop: one thread, many waiting tasks.
  3. Running things at the same time with gather and tasks.
  4. 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.sleep or requests.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 as A 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 to await later. It starts running as soon as the current coroutine reaches its next await:
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 wantUse
Run one coroutine and use its resultawait coro()
Run several and get all resultsawait asyncio.gather(...)
Start something now, collect it laterasyncio.create_task(...), keep the task
Start the whole programasyncio.run(main()), once
Pause briefly without blocking othersawait asyncio.sleep(seconds)

Common mistakes

MistakeWhat happensDo this instead
Calling a coroutine without awaitnothing runs, plus a RuntimeWarningawait it, or make it a task
Awaiting independent calls one by onetheir times add upgather, or create tasks first
A task with no referencemay be garbage-collected mid-runkeep it in a set, or use a TaskGroup
asyncio.run inside async codeRuntimeErrorjust await
time.sleep or heavy computing in a coroutineevery other coroutine stallsawait 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 def function creates a coroutine; it runs only when awaited or made into a task.
  • One thread, one event loop: coroutines take turns at await points - concurrency, not parallelism.
  • Awaits in sequence add up; gather and tasks overlap. gather returns results in argument order.
  • Keep a reference to every task; call asyncio.run once, at the entry point.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You wantUse
Run one coroutine and use its resultawait coro()
Run several, get all results in orderawait asyncio.gather(...)
Start now, collect laterasyncio.create_task(...) - keep the task
Start the programasyncio.run(main()), once
Several runs on one loop (tests)asyncio.Runner()
Pause without blocking othersawait asyncio.sleep(s)
See what a stuck process awaits (3.14)python -m asyncio pstree <PID>

Snippets worth knowing by heart

user, orders = await asyncio.gather(get_user(uid), get_orders(uid))

background = set()
task = asyncio.create_task(audit(event))
background.add(task)
task.add_done_callback(background.discard)

if __name__ == "__main__":
    asyncio.run(main())

The traps

  • Calling a coroutine without await - nothing runs; RuntimeWarning: coroutine '...' was never awaited.
  • Awaiting independent calls one by one - 0.2 + 0.2 + 0.2 = 0.6 s; gather took 0.2 s.
  • gather order - argument order, not finishing order.
  • Fire-and-forget tasks without a reference - can be garbage-collected mid-run.
  • asyncio.run inside a running loop - RuntimeError; just await.
  • Blocking calls or heavy computing in a coroutine - every other coroutine waits.
  • Switches happen only at await - code between awaits is never interrupted by another coroutine.

Staff-level points, one line each

  • A coroutine is a generator underneath: send(None) runs it; return raises StopIteration(value).
  • await delegates down to a Future, which is the only thing that yields to the loop.
  • A Task calls coro.send(); on a yielded future it adds a done-callback and steps away - a waiting task is only that callback.
  • Hence weak references: an unreferenced task waiting on an unreferenced future can be collected.
  • The loop = ready deque + timer heap + selector (kqueue on macOS, epoll on Linux); call_soon runs before call_later.
  • create_task schedules the first step with call_soon; eager_task_factory (3.12) runs it inside create_task.
  • 100,000 sleeping tasks: ~2 s to create, run and gather; ~740 bytes each.
  • Limits at scale: file descriptors, per-callback CPU, buffers - not task count.
  • 3.14: python -m asyncio ps/pstree <PID>, asyncio.capture_call_graph().
  • Debug mode logs callbacks slower than 100 ms and where unawaited coroutines came from.

Say it in an interview

  • "These three calls are independent, so I'll gather them - 200 ms instead of 600."
  • "Everything runs on one thread, so I keep blocking calls off the loop."
  • "I'll keep a reference to background tasks, or put them in a TaskGroup."
  • "Under the hood, await yields a future up to the task, and the loop resumes it when the future completes."

Practice

This skill comes with 11 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python: Coroutines, Tasks and the Event Loop free

Fetch Every Quote at Once

MediumProCoding problemPython

A price-comparison page for a store in Austin shows the current price of every product in a basket. Each price comes from a supplier API through an async function you're given, fetch(sku), which takes a while to answer - each product has its own delay.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Python: Coroutines, Tasks and the Event Loop free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime