Python asyncio: coroutines, async/await & the event loop

Understand asyncio for real — what a coroutine is, how one thread runs many I/O waits at once, and the code that does it: async/await, asyncio.gather, create_task, async for/async with, and when async beats threads.

Programming

Understand Python asyncio for real: what a coroutine is, how the event loop runs many I/O waits concurrently in one thread, async/await, asyncio.gather and create_task, async for / async with, why asyncio.run() fails in a notebook (and top-level await works instead), and when async beats threads.

Published

July 18, 2026

Modified

July 18, 2026

TipKey takeaways
  • asyncio runs many waits at once in a single thread. While one piece of code is blocked waiting on I/O — a network reply, a database row, a file — the event loop runs another. It’s concurrency without threads or processes: no GIL fight, no extra cores, just one thread that never sits idle when there’s other work to do.
  • A coroutine is a function you await. Define it with async def; calling it does nothing — it hands back a coroutine object, a paused recipe (the async cousin of a lazy generator). Only await actually runs it, and await is also where the event loop is allowed to switch to other work.
  • asyncio.gather is the payoff. Ten I/O waits of 0.2 s run one after another take ~2 s; await asyncio.gather(*coros) runs them concurrently in ~0.2 s. asyncio.create_task schedules a coroutine to run in the background; asyncio.as_completed yields results the instant each finishes.
  • asyncio.run(main()) starts the loop — in a script. That is the one entry point that creates the event loop and runs your top-level coroutine. In a notebook or REPL a loop is already running, so asyncio.run() raises RuntimeError — there you await at the top level instead (which is exactly what the cells below do).
  • Reach for async when you have many concurrent I/O waits. Hundreds or thousands of network calls, scraped pages, or DB queries → async scales further than a thread per task. CPU-bound work still needs processes; a handful of blocking calls is simpler with threads. The catch: async “colours” your whole call stack — once one function is async, its callers must be too.

Introduction

You keep seeing async def, await, and asyncio — in web frameworks, HTTP clients, database drivers — and you want to actually understand them, not just paste them. Here is the whole idea in one sentence: asyncio lets a single thread juggle many jobs that spend their time waiting, by switching to another job whenever the current one blocks on I/O.

Think of one cook in a kitchen. A thread-based approach hires more cooks (more threads, more cores). asyncio keeps one cook who, the moment a pot goes on to boil, doesn’t stand and stare at it — they start chopping the next dish, stir a third, and come back to the pot when it’s ready. The cook is never doing two things at the same instant, but nothing waits idle. That’s cooperative concurrency: the code voluntarily hands control back — at each await — so the event loop (asyncio’s scheduler) can run something else while this task waits.

This is a different mental model from the threads-and-processes lesson on parallel and concurrent Python, and it’s the third answer in that lesson’s decision: CPU-bound work → processes, I/O-bound work → threads or async. Async wins when the number of concurrent I/O waits is large — thousands of network connections — because one thread juggling thousands of waits costs far less memory and scheduling overhead than thousands of threads.

We’ll build it up in the order it clicks. First coroutines (async def / await) — what an awaitable actually is. Then the payoff, asyncio.gather, with a real executable timing demo (ten waits, concurrent, in a fraction of the serial time). Then asyncio.run and the event loop — including the honest reason asyncio.run() fails in a notebook. Then async for / async with briefly, a plain decision table for when async is the right tool, and the real gotchas.

A note on how the code runs. The demos below simulate I/O with asyncio.sleep(...) — a non-blocking wait that stands in for a network or database call, so nothing hits the real network at build time and the timings are honest and reproducible. And they use top-level await directly, because this page is rendered in a notebook-style kernel that already has a running event loop. In a standalone .py script you’d instead wrap the entry point in asyncio.run(main()) — the difference is a lesson in itself, covered below.

Coroutines: async def and await

The building block of asyncio is the coroutine. You write one by putting async in front of def. The crucial, surprising thing: calling a coroutine function does not run its body. It hands back a coroutine object — a paused recipe that does nothing until you await it. (If that feels familiar, it’s the async echo of a generator, which also does no work until you iterate it.)

import asyncio

async def greet(name):
    await asyncio.sleep(0.1)          # a NON-blocking wait: yields to the loop
    return f"hello, {name}"

coro = greet("Ada")                   # calling it runs NOTHING — no output yet
print("calling gave us a:", type(coro).__name__)

result = await coro                   # await actually runs it to completion
print(result)
calling gave us a: coroutine
hello, Ada

Two things happened. First, greet("Ada") printed nothing and returned an object whose type is coroutine — the body never ran; we just got a suspended recipe. Then await coro actually executed it: it ran up to the await asyncio.sleep(0.1), released control to the event loop for that 0.1 s (during which the loop could run other work), resumed, and returned hello, Ada. That is the entire contract of await: run this awaitable, and while it’s waiting, let the event loop do something else. asyncio.sleep is the async, non-blocking sleep — unlike time.sleep, which would freeze the whole thread and defeat the point (see Common issues).

By itself, awaiting one coroutine is nothing special — it ran, we waited, we got a result, in sequence. The power appears the moment you have several coroutines and let their waits overlap.

Running many at once: asyncio.gather

Here is the payoff. Say you have ten “fetches”, each a 0.2-second I/O wait. Awaited one after another, the waits stack end to end — about 2 seconds. But if you launch them together and let the event loop interleave their waits, they finish in roughly the time of a single wait. asyncio.gather(*coros) does exactly that: it runs all the coroutines you hand it concurrently and returns their results as a list, in the order you passed them.

First the slow, sequential baseline — each await completes before the next begins:

import asyncio, time

async def fetch(name, delay):
    await asyncio.sleep(delay)        # stand-in for a network / DB / disk wait
    return f"{name} done"

# Sequential: ten 0.2s waits, one after another.
start = time.perf_counter()
sequential = []
for i in range(10):
    sequential.append(await fetch(f"task-{i}", 0.2))
seq_time = time.perf_counter() - start

print(f"sequential: {seq_time:.2f} s")
print(sequential[:3])
sequential: 2.01 s
['task-0 done', 'task-1 done', 'task-2 done']

As expected, the sequential loop took about 2 s — ten 0.2-second waits laid end to end (10 × 0.2 = 2.0). Each await fully finished before the next fetch even started, so nothing overlapped. Now the same ten fetches through asyncio.gather:

# Concurrent: launch all ten, let their waits overlap on one thread.
start = time.perf_counter()
concurrent = await asyncio.gather(*(fetch(f"task-{i}", 0.2) for i in range(10)))
gather_time = time.perf_counter() - start

print(f"gather:     {gather_time:.2f} s")
print(f"speedup:    {seq_time / gather_time:.1f}x")
print("same results, in order:", concurrent == sequential)
gather:     0.20 s
speedup:    10.0x
same results, in order: True

The concurrent run dropped to about 0.2 s — the ten waits happened at the same time instead of in series, a ~10× speedup — and gather returned the results in input order, so concurrent == sequential is True. No threads and no extra cores were involved: one thread started all ten asyncio.sleep waits, the event loop parked each and moved on, and woke them as their timers fired. That is asyncio’s whole trick — and it only works because the tasks wait (on asyncio.sleep here, on the network in real code). If fetch were crunching numbers instead, there’d be no await to yield at, and gather would run them one at a time with no speedup at all.

Scheduling with create_task, and results as they land

gather is the common case, but two companions are worth knowing. asyncio.create_task(coro) schedules a coroutine to start running in the background now (returning a Task you can await later), rather than only when you await it — useful to kick work off while you do something else. And asyncio.as_completed(tasks) yields each task the moment it finishes, regardless of the order you started them — perfect for reacting to results as they arrive. Here three “downloads” of different durations finish shortest-first:

async def download(name, seconds):
    await asyncio.sleep(seconds)      # bigger files "take" longer
    return name, seconds

jobs = [("large.json", 0.3), ("small.json", 0.1), ("medium.json", 0.2)]

# create_task schedules each one to start running right away.
tasks = [asyncio.create_task(download(name, secs)) for name, secs in jobs]

# as_completed hands us each task as it finishes — shortest wait first.
for finished in asyncio.as_completed(tasks):
    name, secs = await finished       # await unwraps the task's return value
    print(f"done: {name} (waited {secs}s)")
done: small.json (waited 0.1s)
done: medium.json (waited 0.2s)
done: large.json (waited 0.3s)

Even though we scheduled them large-first, they completed shortest-first — small.json (0.1 s), then medium.json (0.2 s), then large.json (0.3 s) — because as_completed yields whichever task’s wait ends next. create_task started all three running concurrently the instant we built the list; as_completed then let us handle each result the moment it was ready (say, to update a progress bar), instead of blocking on a fixed order. Use gather when you want every result together in order; reach for create_task + as_completed when you want to act on results as they land.

asyncio.run and the event loop

Everything so far ran on an event loop — asyncio’s scheduler, the thing that keeps track of which coroutines are waiting and which are ready to resume. The natural question: who starts the loop? In a real program the answer is asyncio.run(main()): it creates a fresh event loop, runs your top-level coroutine (conventionally named main) until it finishes, and cleans the loop up. It is the one place a script crosses from ordinary synchronous code into the async world. In a standalone file you’d write:

# app.py  —  save this to a file and run it:  python app.py
import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    # inside a coroutine, await freely:
    results = await asyncio.gather(fetch("a", 0.2), fetch("b", 0.2), fetch("c", 0.2))
    print(results)

asyncio.run(main())        # the ONE call that starts the event loop

Run that as a script and it prints ['a done', 'b done', 'c done'] in about 0.2 s.

Now the honest nuance — and it’s one of the most-searched asyncio errors. In a notebook, IPython, or an async REPL, an event loop is already running. Calling asyncio.run() there raises:

RuntimeError: asyncio.run() cannot be called from a running event loop

because asyncio.run() tries to create and run a new loop, and you can’t start one while one is already spinning. That’s not a bug to work around — it’s the design. When a loop is already running, you don’t need asyncio.run at all: you await at the top level directly, which is precisely what the executable cells above do (result = await coro, await asyncio.gather(...)). So the rule is:

  • In a .py script (no loop yet): define async def main(), and start it with asyncio.run(main()).
  • In a notebook / REPL (a loop is already running): drop the asyncio.run wrapper and await at the top level — the environment’s loop runs it for you.

Same coroutines either way; only the entry point differs. Knowing which situation you’re in explains the RuntimeError the first time it bites you.

async for and async with

Two more pieces of async syntax round out the model, both mirroring ordinary Python. An async with block is an asynchronous context manager — a with whose setup and teardown can themselves await (opening a network session, acquiring a connection from a pool). An async for loop iterates an async generator or async stream — the async cousin of the generator you met earlier — awaiting the next item each time round, so the loop can yield to the event loop between elements.

The canonical real-world example is the HTTP client aiohttp — the async analogue of requests. Both markers appear at once (this is illustrative; run it locally with aiohttp installed — the render never touches the network):

# run this locally:  pip install aiohttp
import aiohttp, asyncio

async def fetch_json(url):
    async with aiohttp.ClientSession() as session:      # async context manager
        async with session.get(url) as response:        # awaits the connection
            return await response.json()                 # awaits the body

async def main():
    urls = ["https://api.example.com/1", "https://api.example.com/2"]
    # gather runs the requests concurrently — the real payoff of async I/O:
    return await asyncio.gather(*(fetch_json(u) for u in urls))

asyncio.run(main())

The two async with blocks make sure the session and the response are opened and closed correctly even though those steps involve awaiting the network; await response.json() reads the body without blocking the thread; and wrapping the calls in asyncio.gather is what turns “fetch these URLs” into “fetch these URLs all at once.” An async for would look just like a normal forasync for row in cursor: over a database driver’s streaming result, or async for chunk in response.content: — awaiting each item as it arrives. The takeaway: async with and async for are the await-aware versions of with and for, and you use them wherever the setup, teardown, or each iteration step is itself an I/O wait.

When to use asyncio

Async is powerful but it is not free, so match it to the workload. Here is the decision, extending the table from the parallel-and-concurrent lesson:

Your work is… Example Best tool Why
I/O-bound, very many concurrent waits Thousands of network requests, a web scraper, many DB queries, a chat/websocket server async (asyncio + an async library) One thread juggles thousands of waits with far less memory/overhead than a thread each
I/O-bound, a handful of waits A few dozen API calls or file reads Threads (ThreadPoolExecutor) Simpler — no need to make the whole call stack async; the GIL is released while waiting
CPU-bound Number-crunching, parsing, compression, image/ML work Processes (ProcessPoolExecutor) async can’t help — there’s no await to yield at; you need multiple cores
A blocking call inside async code A legacy sync library, a CPU-heavy step await loop.run_in_executor(...) Push the blocking work to a thread/process pool so it doesn’t freeze the loop

Two honest caveats. First, async colours your whole call stack. The moment one function is async, everything that awaits it must be async too, all the way up to asyncio.run (or top-level await) — you can’t sprinkle await into ordinary sync code. That “all-or-nothing” spread is the real cost of adopting asyncio, and the reason a few blocking calls are often better served by a thread pool. Second, async needs async-aware libraries. Calling a normal blocking function (plain requests, a synchronous DB driver, time.sleep) inside a coroutine blocks the entire event loop — every other task stalls — because there’s no await for the loop to switch on. You need the async-native library (aiohttp instead of requests, an async DB driver) or you must offload the blocking call with run_in_executor.

So: many concurrent I/O waits → async; a few → threads; computing → processes. That single sentence is the whole decision.

The same idea in R

Working across both languages? R has no direct asyncio analogue — no async/await keywords or event-loop model built into the language. The nearest tools are the future/promises packages (asynchronous evaluation, used heavily inside Shiny to keep an app responsive) and later (schedule a function to run on the event loop). For “run many independent tasks” R more often reaches for the parallelism tools in the parallel-and-concurrent lesson’s R note (future/furrr). If you need cooperative single-thread I/O concurrency the way Python’s asyncio provides it, that’s genuinely a place where Python’s model is more first-class. See the Programming pillar for the R side.

NoteThis lesson is reproducible

Every result above was produced by the code shown. The executable {python} blocks ran at build time using top-level await (if they render, the code works), so you can copy any block and run it — with one caveat about where: top-level await works in a notebook, IPython, or an async REPL (a loop is already running for you). In a plain .py script, wrap the entry point in asyncio.run(main()) instead, exactly as the illustrative script blocks show. The aiohttp snippet is illustrative — install aiohttp and run it locally; the render never touches the network. There is no in-browser sandbox on this page, so the workflow is copy-and-run-locally, not edit-and-re-render.

🟢 With an AI agent

Got a slow stretch of code that fires off network calls or DB queries one after another? Paste it and ask Prova “rewrite this to run the I/O concurrently with asyncio.gather”. It turns your blocking functions into coroutines, wires up async def/await, and gathers the calls so the waits overlap instead of stacking — and it’ll tell you honestly if the work is CPU-bound and async is the wrong tool. Then copy the code, run it on your own machine, and time both versions — because the real test of “did this get faster?” is the stopwatch, not a plausible explanation. The runtime is the judge. Ask Prova →

Common issues

RuntimeError: asyncio.run() cannot be called from a running event loop. You called asyncio.run(main()) inside a notebook, IPython, or another async context where a loop is already running — and asyncio.run insists on creating and running a new one. The fix depends on where you are: in a notebook/REPL, drop the wrapper and await at the top level (await main() or await asyncio.gather(...)), because the environment’s loop will run it. In a script, asyncio.run(main()) is correct — that’s the one place there’s no loop yet. (Don’t reach for nest_asyncio to force a nested loop; use top-level await.)

“coroutine was never awaited” — calling a coroutine does nothing. Writing fetch("a", 0.2) without await builds a coroutine object and throws it away; the body never runs, and Python warns RuntimeWarning: coroutine 'fetch' was never awaited. A coroutine only executes when you await it, pass it to asyncio.gather, or schedule it with asyncio.create_task. If your async function “silently does nothing,” you almost certainly forgot the await (or forgot to gather/await the tasks you created).

You used time.sleep (or a blocking call) and everything froze. time.sleep(1) blocks the entire thread — and with it the whole event loop, so every other task stalls for that second. Inside async code, wait with await asyncio.sleep(1) instead: it yields to the loop so other coroutines run meanwhile. The same trap applies to any synchronous blocking call (plain requests.get, a sync DB driver, a heavy CPU loop): either use the async-native equivalent, or offload it with await loop.run_in_executor(None, blocking_fn, *args) so it runs on a thread/process pool and doesn’t freeze the loop.

Async “infects” your whole call stack. You made one function async and suddenly its callers won’t work until they’re async too. That’s by design: you can only await inside a coroutine, so async propagates up to asyncio.run (or top-level await). If you don’t want to convert an entire code path, that’s a signal async may be the wrong tool here — a thread pool lets a few blocking calls run concurrently without turning your whole program async.

Frequently asked questions

A coroutine is a function defined with async def. Unlike an ordinary function, calling it doesn’t run its body — it returns a coroutine object, a paused recipe that does nothing until you await it (much like a generator does no work until you iterate it). Inside a coroutine, await runs another awaitable and, while that awaitable is waiting on I/O, hands control back to the event loop so other coroutines can run. So a coroutine is the unit of work asyncio schedules: you write straight-line code with async/await, and the event loop interleaves many coroutines’ waits on a single thread.

Both handle I/O-bound work (waiting on network/disk), but differently. Threads let the OS switch between multiple threads pre-emptively; a waiting thread releases the GIL so others run. They’re simple and don’t require rewriting your code as async, but each thread costs real memory, so thousands of them get expensive. async runs everything on one thread and switches cooperatively — only at each await — so it juggles thousands of concurrent waits with far less overhead, which is why it scales to huge numbers of connections. The trade-offs: async requires async-aware libraries and spreads async through your whole call stack; threads work with ordinary blocking code but scale less far. Neither speeds up CPU-bound work — that needs processes.

asyncio.create_task(coro) schedules a single coroutine to start running in the background immediately and returns a Task handle you can await (or check) later. asyncio.gather(*coros) is a higher-level convenience: it schedules several coroutines to run concurrently and waits for all of them, returning their results as a list in the order you passed them. Under the hood gather wraps its coroutines as tasks for you. Use gather when you want every result together in input order; use create_task when you want to launch work now and await it later, or combine it with asyncio.as_completed to handle results as each one finishes.

Use asyncio when you have many concurrent I/O-bound waits — hundreds or thousands of network requests, a web scraper, many database queries, a websocket/chat server — because one thread can juggle all those waits with far less memory and overhead than a thread per task. Don’t use it for CPU-bound work (number-crunching): there’s no await to yield at, so use processes instead. For just a handful of blocking calls, plain threads (ThreadPoolExecutor) are simpler because you don’t have to make your whole call stack async. And remember async needs async-native libraries (like aiohttp instead of requests) — a blocking call inside a coroutine freezes the entire event loop.

Because asyncio.run() creates and runs a new event loop, and you can only have one loop running per thread — so if a loop is already running, it raises RuntimeError: asyncio.run() cannot be called from a running event loop. This happens in a notebook, IPython, or an async REPL, which start a loop for you. The fix isn’t to force a nested loop — it’s to skip the wrapper and await at the top level directly (await main()), letting the existing loop run your coroutine. asyncio.run(main()) is meant for a standalone script, where no loop exists yet and it’s the single entry point into async code.

Test your understanding

You have three “fetches” run one after another. Rewrite them to run concurrently so the total time is roughly the longest wait, not the sum.

import asyncio, time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    start = time.perf_counter()
    a = await fetch("a", 0.3)          # these run one after another…
    b = await fetch("b", 0.3)
    c = await fetch("c", 0.3)          # …so this takes ~0.9 s total
    print([a, b, c], f"in {time.perf_counter() - start:.2f} s")

asyncio.run(main())

Task 1. Rewrite main() so the three fetches run concurrently and the whole thing finishes in about 0.3 s instead of 0.9 s. Keep the results in order. Task 2. In one sentence, explain why it speeds up even though there is only one thread.

The three await fetch(...) lines each fully finish before the next starts — that’s the serial part. Launch them together with asyncio.gather, which runs all the coroutines concurrently and returns their results in order: results = await asyncio.gather(fetch("a", 0.3), fetch("b", 0.3), fetch("c", 0.3)).

import asyncio, time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(         # run all three concurrently
        fetch("a", 0.3),
        fetch("b", 0.3),
        fetch("c", 0.3),
    )
    print(results, f"in {time.perf_counter() - start:.2f} s")

asyncio.run(main())     # in a notebook, drop asyncio.run and: await main() at top level

asyncio.gather launches all three coroutines at once and returns their results in order (['a done', 'b done', 'c done']). Task 2: the three asyncio.sleep waits overlap — while one coroutine is parked waiting, the event loop runs the others on the same thread, so the total is one 0.3 s wait instead of three stacked end to end. (In a notebook, replace asyncio.run(main()) with a top-level await main(), because a loop is already running.)

Quick check. Inside an async def, you replace await asyncio.sleep(1) with time.sleep(1). Your other coroutines that were running concurrently suddenly stall for a full second each. Why?

time.sleep(1) is a blocking call — it freezes the entire thread for a second, and asyncio runs everything on one thread, so the whole event loop is stuck too: no other coroutine can run until it returns. await asyncio.sleep(1) instead yields to the loop, letting other coroutines run during the wait. The rule: inside async code, never call a blocking function directly — use its async equivalent (asyncio.sleep, aiohttp) or offload it with loop.run_in_executor.

Conclusion

asyncio is one thread that never waits idle: while one coroutine is blocked on I/O, the event loop runs another, giving you concurrency without threads or processes. A coroutine (async def) is a paused recipe that only runs when you await it — and await is also where the loop is free to switch tasks. asyncio.gather is the payoff: many I/O waits that would stack up in series instead overlap, so ten 0.2 s waits finish in ~0.2 s, not ~2 s; create_task schedules work in the background and as_completed hands you results as they land. Start the loop with asyncio.run(main()) in a script — but in a notebook a loop is already running, so you await at the top level instead (the reason asyncio.run() raises there). Reach for async when you have many concurrent I/O waits; use threads for a few and processes for CPU-bound work — and remember that async colours your whole call stack and needs async-aware libraries.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Python Asyncio: Coroutines, Async/Await \& the Event Loop},
  date = {2026-07-18},
  url = {https://www.datanovia.com/learn/programming/python-intermediate/async-python-asyncio},
  langid = {en}
}
For attribution, please cite this work as:
“Python Asyncio: Coroutines, Async/Await & the Event Loop.” 2026. July 18. https://www.datanovia.com/learn/programming/python-intermediate/async-python-asyncio.