AlgoMaster Logo

asyncio Basics (async/await)

High Priority20 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

asyncio is Python's standard library for writing concurrent code that runs in a single thread. It's the right tool when your program spends most of its time waiting for I/O: HTTP responses, database queries, file reads, email deliveries. This lesson covers what asyncio is, why it exists, and how the three building blocks async def, await, and asyncio.run fit together.

What asyncio Is

asyncio is cooperative concurrency in a single thread. There's one Python interpreter, one stack, one execution at a time, but the interpreter can pause a function partway through and run a different one while the first is waiting. When the thing the first function was waiting for finishes, the interpreter comes back and resumes it from where it left off.

The piece that makes this work is the event loop. The event loop is a scheduler: it holds a queue of work, picks one task to run, lets it run until it voluntarily pauses, then picks the next one. Nothing is preempted. A function keeps the CPU until it explicitly says "I'm waiting, run something else."

The thing the function pauses on is an awaitable: an object the event loop knows how to wait for. The most common awaitables are coroutines (the values returned by calling an async def function), Tasks, and Futures. We'll meet coroutines first in this lesson.

Compare this with the threading model. Threads run on real OS threads. The operating system can interrupt a thread at any instruction and switch to another. That's why threaded code needs locks. asyncio is the opposite: a coroutine can only be paused at an await, never anywhere else, so most of the lock dance disappears.

The diagram shows one full cycle of the event loop. The loop picks a ready task, runs it until that task hits an await, parks it while the I/O completes, then goes back to picking another ready task. If the task finishes instead of awaiting, the loop resolves its result and moves on.

Why asyncio Exists

To understand why asyncio is in the standard library at all, think about what happens when you fetch ten product prices from an external service one at a time. Each fetch takes maybe 200 milliseconds. Most of that 200ms isn't your CPU doing work, it's your program waiting for the network. Synchronously, the ten fetches take 2 seconds: 200ms times ten.

You could fix this with threads, one per fetch. That works, but it has costs: each thread carries memory overhead (typically tens of kilobytes of stack), the OS scheduler is involved on every context switch, and shared state between threads forces you to think about locks. Threads are general-purpose, but for "I'm just waiting on the network" workloads they're heavy.

asyncio solves the same problem differently. One thread, one event loop, ten coroutines. While one coroutine is waiting on its socket, the loop runs the next one. The total wall time drops from 2 seconds to roughly 200ms, the time of the slowest single request, and you didn't spawn a single thread.

That's the whole pitch: high concurrency for I/O-bound work without thread overhead. asyncio is not useful for CPU-bound work; a coroutine that crunches numbers for 5 seconds blocks the entire event loop for those 5 seconds, because there's no preemption.

async def Defines a Coroutine

A function defined with async def is a coroutine function. Calling it does not run the body. It returns a coroutine object, which is a value the event loop knows how to run later.

Two things to notice. First, print showed a coroutine object, not the number 29.99. The body of fetch_price never ran. Second, Python warned that the coroutine was never awaited, because once you create one, the runtime expects something to actually drive it.

This is the most common surprise for newcomers. In normal Python, calling a function runs it. With async def, calling it just builds a paused "I'm going to run this someday" object. The "someday" happens when you await it, or hand it to the event loop with asyncio.run or one of its task-creation helpers.

The body of a coroutine can do anything a normal function can. It can have arguments, return values, raise exceptions, use type hints. The two new powers are: it can use await inside, and the caller has to be either another coroutine or asyncio.run.

Now fetch_price actually runs, because main awaits it, and main actually runs because asyncio.run drives it.

await Pauses a Coroutine

await is the keyword that hands control back to the event loop. The shape of the expression is await <awaitable>. The current coroutine pauses, the loop is free to run something else, and when the awaited thing produces a result, the coroutine resumes with that result as the value of the await expression.

The await fetch_price(...) does two things at once: it creates the coroutine, then hands it to the loop and waits for it to finish. While fetch_price is inside its own await asyncio.sleep(0.2), the loop is free, even though in this tiny example there's nothing else to run.

await only works inside async def. Trying to use it at the module level or inside a regular def is a SyntaxError:

This is one of the two big rules of asyncio. The first is "calling an async def returns a coroutine, not a value." The second is "await only works inside async def." Together they mean once you go async, the asyncness flows up the call stack until something at the top runs the loop.

You can await three things: a coroutine, a Task, or a Future. The standard library also provides built-in awaitables like asyncio.sleep, which is the asyncio-friendly version of time.sleep.

asyncio.run Is the Entry Point

asyncio.run(coro) is how a synchronous program enters asyncio. It does three jobs in order:

  1. Creates a new event loop.
  2. Runs the given coroutine on that loop until it returns (or raises).
  3. Closes the loop, including cancelling any leftover tasks and closing any open resources.

You call it once, at the top of your program, with a single coroutine that represents the whole async portion of your code.

The line after asyncio.run doesn't execute until the loop has fully shut down. That's by design: asyncio.run is a synchronous call from the caller's point of view, and it blocks until the coroutine is finished.

A few rules about asyncio.run to keep in mind:

  • It creates a fresh loop each time it's called. Don't call it from inside another running loop; that raises RuntimeError: asyncio.run() cannot be called from a running event loop. If you're already in an async function, await the coroutine directly instead of wrapping it in another run.
  • The argument has to be a coroutine, not a coroutine function. asyncio.run(main) is wrong; asyncio.run(main()) is right.
  • It was added in Python 3.7. Older code might use asyncio.get_event_loop().run_until_complete(...), which is the older, more verbose way. Use asyncio.run in new code.

The pattern in almost every async Python program looks like this:

One coroutine called main, one call to asyncio.run at the module entry point. Everything async lives below main.

asyncio.sleep vs time.sleep

asyncio.sleep(seconds) is the asyncio-friendly version of time.sleep. It pauses the current coroutine for the given number of seconds without blocking the event loop. While the coroutine is sleeping, the loop is free to run other coroutines.

time.sleep blocks the whole thread. If you call it inside a coroutine, the loop and every other coroutine on it stops dead for the duration. This is the single most common asyncio bug. Use asyncio.sleep to simulate I/O in examples and use real async libraries (like aiohttp or aiosqlite) for real I/O.

A side-by-side trace makes the difference clear:

CallBlocks the thread?Blocks the event loop?Other coroutines run during the wait?
time.sleep(1)YesYesNo
await asyncio.sleep(1)NoNoYes

The two functions look almost identical in code, but their effect on a program with many coroutines is opposite.

Concurrent Coroutines: The Wall-Time Demo

Here's where asyncio earns its keep. We'll run two coroutines that each "do I/O" for half a second, and we'll measure how long the whole thing takes.

First, the wrong way. Awaiting one after the other gives sequential execution: total time equals the sum of the two waits.

Two half-second waits, one second total. The second await doesn't start until the first one finishes, because await waits for the awaitable to complete before moving on. Sequential, not concurrent.

To run them concurrently we need to tell the loop "start both, then wait for both." The standard way is asyncio.gather:

Two half-second waits, half a second total. Both coroutines started before either finished, ran their await asyncio.sleep(0.5) at the same wall-clock time, and the loop resumed both when the timer fired. Concurrency for free.

Wall time went from 1 second to 0.5 second. With ten coroutines that each wait 0.5 seconds, the sequential version takes 5 seconds and the concurrent version still takes 0.5. That's the asyncio sweet spot: more I/O-bound work makes the gap bigger, because the loop overlaps more of the waits.

The point to take away from this demo is that await alone is sequential: it's how a coroutine waits for something. To get concurrency, you need a tool that says "start these and wait for all of them," and asyncio provides several.

A Mental Model of the Event Loop

To make the cooperative-scheduling story concrete, here's a step-by-step walkthrough of what the loop does during the concurrent version of the demo above.

The key idea is the parking step. When fetch_price("wireless-mouse") hits await asyncio.sleep(0.5), it doesn't sit and spin. It tells the loop "wake me up in 0.5 seconds" and returns control. The loop then has nothing to run, so it immediately picks fetch_price("usb-cable"), which also runs until its own await asyncio.sleep(0.5) and parks. Now the loop is genuinely idle for half a second, after which both timers fire and both coroutines get resumed in order.

If you swapped await asyncio.sleep(0.5) for time.sleep(0.5), the parking step would disappear. The thread would block in time.sleep, the loop wouldn't get a chance to schedule the second coroutine, and total time would go back to 1 second. The whole concurrency benefit comes from the fact that asyncio-aware functions yield instead of blocking.

Common Beginner Pitfalls

A handful of mistakes show up over and over when people start writing asyncio code. They're worth naming so you can recognize them.

Forgetting to `await`. Calling a coroutine without await returns a coroutine object and prints a RuntimeWarning instead of actually running anything.

The notification was never sent because send_notification(...) only built a coroutine object and threw it away. The fix is to add await:

Using `time.sleep` inside a coroutine. This is the silent killer. The code runs, the output looks right, but concurrency is gone because time.sleep blocks the whole thread.

The fix is await asyncio.sleep(0.5). The same rule applies to any synchronous I/O library: requests.get, open(...), pymysql, etc. They all block. For real async code you use async-native libraries like aiohttp, aiofiles, and aiomysql, or you delegate the blocking call to a thread.

Mixing `asyncio.run` with an already-running loop. Calling asyncio.run from inside a function that's already on the loop raises RuntimeError. The fix is to await the coroutine directly when you're already async, and reserve asyncio.run for the synchronous entry point.

Replace with:

Awaiting a non-awaitable. await 5 is a TypeError. await requires a coroutine, Task, or Future. Returning a value from an async def is fine; that's what the coroutine awaits to. But trying to await a plain integer doesn't work.

Top-Level await and the asyncio REPL

You can't write await at the top level of a normal Python script:

The reason is the rule we already saw: await is only legal inside async def. A .py file's top level is plain synchronous code, so await has nowhere to live.

There's one exception, and it's worth knowing because it makes experimenting easier. The asyncio REPL lets you use top-level await interactively. Launch it with:

Now await works at the prompt:

This is purely a convenience for exploring async APIs without writing asyncio.run(main()) over and over. The regular python REPL does not allow top-level await. Jupyter notebooks also allow top-level await because they wrap each cell in an async context, which is why example notebooks for async libraries usually just work.

For real programs, asyncio.run(main()) at the bottom of the file is still the way to go.

A Slightly Bigger Example: Notification Emails

Tying the pieces together, here's a small program that sends order confirmation emails to several customers concurrently. The example uses asyncio.sleep to simulate the latency of an outbound email service.

Four emails, 0.3 seconds each, total 0.3 seconds. Sequentially this would have taken 1.2 seconds. The "Sending..." lines all print before any "Sent..." line, which is the visible signature of cooperative concurrency: every coroutine got to its first await before any of them resumed.

This example uses asyncio.gather to start all four coroutines at once. The takeaway is that once you have async-friendly building blocks, fanning out work is one call.

Quiz

asyncio Basics Quiz

10 quizzes