By WebNest Studio Engineering Team
Python Tutorial
Async Programming with asyncio
Traditional Python code runs synchronously: each statement waits for the previous one to finish before starting, and if a statement is a slow network call, the entire program sits idle waiting for it. The asyncio module offers a different approach for I/O-bound programs — a single-threaded event loop that can juggle many waiting operations at once, running other code while any individual operation is blocked on I/O, without the overhead of spawning separate OS threads.
This lesson covers the async/await syntax, what the event loop actually does, starting a program with asyncio.run(), awaiting coroutines, running many of them concurrently with asyncio.gather(), and — just as importantly — when asyncio genuinely helps versus when it does nothing for you at all.
Why it matters
A web server spends most of its time waiting: for a database, another API or the network. With asyncio a single thread can handle thousands of such waits at once, which is how FastAPI serves many requests efficiently. The async and await keywords are now part of everyday Python web development.
async/await Syntax and Coroutines
A function defined with async def is a coroutine function; calling it does not run its body immediately — it returns a coroutine object, which must be awaited or scheduled to actually execute. Inside an async function, the await keyword pauses execution at that point until the awaited operation completes, and — critically — while it's paused, the event loop is free to run other coroutines instead of sitting idle. You can only use await inside a function defined with async def.
The Event Loop and asyncio.run()
The event loop is the engine that drives all of this: it keeps track of every pending coroutine, resumes each one when the thing it was awaiting becomes ready, and cycles through them in a single thread. You rarely manage the event loop directly in modern asyncio code; instead, asyncio.run(main()) creates a new event loop, runs your top-level coroutine (main) to completion, and cleans the loop up afterward. This is the standard entry point for any asyncio program.
Running Coroutines Concurrently with asyncio.gather()
Awaiting one coroutine after another with separate await statements still runs them sequentially — each one finishes before the next starts, gaining nothing from asyncio. To actually run several coroutines concurrently, pass them to asyncio.gather(), which schedules all of them on the event loop at once and returns their results together once every one of them has completed. This is where asyncio's real benefit shows up: several slow I/O operations overlapping in time instead of running one after another.
await some_coroutine()— runs and waits for just that one coroutine.await asyncio.gather(coro1(), coro2(), coro3())— runs all three concurrently and waits for all to finish.asyncio.run(main())— the standard way to start the event loop and run your top-level async function.
When asyncio Helps — and When It Does Not
asyncio shines for I/O-bound work: network requests, database queries (with an async driver), reading many files, or waiting on external services — anywhere a task spends most of its time waiting rather than computing. It does essentially nothing for CPU-bound work like heavy numeric computation or image processing, because Python's event loop is still single-threaded; a long CPU-bound coroutine that never awaits will block the entire event loop and freeze every other "concurrent" task. For CPU-bound parallelism, the multiprocessing module (which uses separate OS processes, sidestepping the GIL) is the correct tool, not asyncio.
Example
Two simulated I/O calls run sequentially vs. concurrently with asyncio.gather()
import asyncio
import time
async def fetch_data(name: str, delay: float) -> str:
await asyncio.sleep(delay) # simulates a non-blocking network call
return f"{name} done"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
fetch_data("A", 1),
fetch_data("B", 1),
)
elapsed = time.perf_counter() - start
print(results)
print(f"Elapsed: {elapsed:.1f}s")
asyncio.run(main())
['A done', 'B done']
Elapsed: 1.0s
Common Mistakes
- Calling an async function without await (or without passing it to asyncio.run/gather) and being confused when nothing happens — it just creates an unused coroutine object.
- Awaiting coroutines one at a time in sequence and expecting concurrency — sequential awaits still run one after another; use asyncio.gather() for real concurrency.
- Using asyncio to speed up CPU-bound work like heavy computation, which gains nothing because the single-threaded event loop still executes all Python bytecode one step at a time — multiprocessing is needed instead.
- Calling a blocking, synchronous function (like a non-async database driver call) inside a coroutine, which blocks the entire event loop for every other task, not just the current one.
Exercise
Write a coroutine fetch that waits for the given delay and then returns the name in upper case. In main, run two of them at the same time with asyncio.gather and print the list of results.
Expected output: ['A', 'B']
import asyncio
async def fetch(name, delay):
# TODO: wait for "delay" seconds without blocking, then return the name in upper case
pass
async def main():
# TODO: run fetch("a", 0.02) and fetch("b", 0.01) concurrently and print the results
pass
asyncio.run(main())
Show hints
- The non-blocking wait is
await asyncio.sleep(delay). await asyncio.gather(first, second)returns the results in the order the coroutines were passed, not the order they finished.
Show solution
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay)
return name.upper()
async def main():
results = await asyncio.gather(fetch("a", 0.02), fetch("b", 0.01))
print(results)
asyncio.run(main())
Quick quiz
1. What does calling an async def function, without await, return?
- A. Its result
- B. A coroutine object; the body has not run yet
- C. None
- D. A thread
Show answer
B. The coroutine runs when it is awaited or scheduled on the event loop.
2. What happens if time.sleep(5) is called inside a coroutine?
- A. The whole event loop is blocked for five seconds
- B. Only that coroutine pauses
- C. It raises an error
- D. It is converted to asyncio.sleep
Show answer
A. Blocking calls stop every task. Use await asyncio.sleep, or run blocking code in a thread.
3. For which kind of work does asyncio give no benefit?
- A. Many network requests
- B. Heavy calculations that keep the CPU busy
- C. Reading from several sockets
- D. Waiting for database queries
Show answer
B. There is one thread, so a long calculation blocks everything. Use processes for CPU-bound work.
Interview questions
What is the event loop?
Show answer
The event loop is the scheduler at the centre of asyncio. It runs one task until that task reaches an await on something that is not ready, such as a network response, then switches to another task that can make progress. When the awaited operation completes, the first task is resumed. All of this happens on one thread, so tasks cooperate by yielding at await points.
What is the difference between asyncio and multithreading?
Show answer
Both handle many I/O waits concurrently. With threads, the operating system switches between them at any moment, so shared data needs locks. With asyncio, there is one thread and a task gives up control only at an await, which makes shared state easier to reason about and allows far more simultaneous tasks than threads. The cost is that every library used must be asynchronous, and a blocking call stalls everything.
Key Points to Remember
- async def defines a coroutine function; await pauses a coroutine while letting the event loop run other work instead of blocking the whole program.
- asyncio.run() is the standard entry point that creates the event loop and runs your top-level coroutine to completion.
- asyncio.gather() runs multiple coroutines concurrently and waits for all of them, which is where real time savings come from versus sequential awaits.
- asyncio helps I/O-bound workloads (network, disk, external services) but does nothing for CPU-bound work, which needs multiprocessing instead.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs Python in your browser. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering