Here is a puzzle. You fire off ten network requests, each one takes about a second, and the whole thing finishes in one second flat. No extra threads. No extra Central Processing Unit (CPU) cores. Just one thread, running one task at a time. How? Welcome to Python asyncio, where a single thread juggles thousands of waiting operations by never sitting idle.
“Concurrency is about structure, parallelism is about execution.”
Rob Pike, Concurrency Is Not Parallelism
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 20 minutes
Think about a single chef in a small kitchen. They put a pot of water on to boil, and instead of standing there staring at it, they start chopping onions. When the timer for the pasta goes off, they come back to it. One person, one pair of hands, but several dishes moving forward at once because nobody wastes time waiting. That is exactly how the asyncio event loop works. The chef is your single thread. Each await is the chef saying “this will take a while, I will start something else and come back.”
This is cooperative multitasking. Threads are preemptive: the operating system (OS) can pause a thread at any instant, even in the middle of a line. Coroutines are different. They are cooperative, which means they only ever hand back control at an await point, on purpose. Nothing interrupts them mid-step. That single fact buys you a lot: far fewer race conditions, usually no locks, and the ability to keep thousands of connections open without thousands of thread stacks eating your memory.
A backend developer named Niranjan hit this in production. He built an Application Programming Interface (API) aggregator that called 50 different endpoints, one after another, with the plain requests library. It took about 30 seconds, almost all of it spent waiting on the network. He rewrote it with asyncio so all 50 requests waited at the same time, and it dropped to roughly 2 seconds. Same network, same data. The only thing that changed was that the waiting now overlapped instead of stacking up one piece at a time.
Read the diagram top to bottom and you have the whole mental model. The event loop pulls a coroutine off the task queue and runs it until it hits an await. At that point the coroutine yields control instead of blocking, so the loop switches to the next ready task. Meanwhile the operating system handles the slow part (the network read, the disk write, the timer). When that finishes, the OS signals the loop, the original coroutine becomes ready again, and it resumes from exactly where it paused. The key idea to hold onto: await is not the same as blocking. It is the polite moment where your coroutine steps aside so something else can run.
Table of Contents
Prerequisites
Read Multithreading first, because Python asyncio makes the most sense once you have felt the pain threads solve and the pain they create. Decorators helps too. If you have seen yield from the generators tutorial, you already understand the core trick, since coroutines grew straight out of generators. All code here runs on Python 3.14.6.
The Simplest Possible Example
Three greetings for three friends, Rahul, Viraj, and Pravin, each pausing for a different number of seconds. Watch the order they finish in.
📄 async_basics.py: your first coroutines running together
import asyncio
async def greet(name, delay):
"""A coroutine: note the 'async def'."""
print(f"Hello, {name}!")
await asyncio.sleep(delay) # Non-blocking pause, not time.sleep
print(f"Goodbye, {name}! (after {delay}s)")
return f"{name} done"
async def main():
# Run three coroutines together and wait for all of them
results = await asyncio.gather(
greet("Rahul", 2),
greet("Viraj", 1),
greet("Pravin", 3),
)
print(f"Results: {results}")
asyncio.run(main()) # The modern entry point (Python 3.7 and later)
▶ Output
Hello, Rahul! Hello, Viraj! Hello, Pravin! Goodbye, Viraj! (after 1s) Goodbye, Rahul! (after 2s) Goodbye, Pravin! (after 3s) Results: ['Rahul done', 'Viraj done', 'Pravin done']
What happened here: All three “Hello” lines print first, back to back, because each greet call runs right up to its await asyncio.sleep(...) and then steps aside. Now the event loop has three coroutines all napping. Viraj asked for the shortest nap (1 second) so he wakes first, then Rahul at 2 seconds, then Pravin at 3. The total wall time is 3 seconds, the length of the longest sleep, not 6 seconds (the sum).
That is the entire point of asyncio in one example: the waiting overlaps. One thing to notice about asyncio.gather: it returns the results in the order you passed the coroutines in, not the order they finished. Pravin is last in the list even though he prints first inside the loop.
Watching the Event Loop Switch
The first example hints at the switching, but a timestamp makes it undeniable. It is like reading the timestamps in a group chat: you do not have to guess who replied first, the times are printed right there. This version stamps every line with how many seconds have passed since the program started, so you can literally see three downloads starting at the same instant and finishing whenever their own timer runs out.
📄 event_loop_trace.py: timestamps prove the overlap
import asyncio
import time
start = time.perf_counter()
def stamp():
return f"{time.perf_counter() - start:4.1f}s"
async def download(name, seconds):
print(f"[{stamp()}] {name}: starting")
await asyncio.sleep(seconds) # pretend this is a network call
print(f"[{stamp()}] {name}: finished")
return name
async def main():
await asyncio.gather(
download("file-A", 3),
download("file-B", 1),
download("file-C", 2),
)
print(f"[{stamp()}] all done")
asyncio.run(main())
▶ Output
[ 0.0s] file-A: starting [ 0.0s] file-B: starting [ 0.0s] file-C: starting [ 1.0s] file-B: finished [ 2.0s] file-C: finished [ 3.0s] file-A: finished [ 3.0s] all done
What happened here: Trace it step by step. At 0.0s the loop runs file-A until it hits await, then file-B, then file-C. All three are now parked on a timer, so all three “starting” lines share the same 0.0s stamp. The loop has nothing ready to run, so it sits and waits for the soonest timer. At 1.0s, file-B’s timer fires, the loop wakes it, and it prints “finished”. Same story at 2.0s for file-C and 3.0s for file-A. The whole run takes 3 seconds, the single longest wait. Run it yourself and the numbers land within a few milliseconds of these, because the only thing happening is sleeping.
The Catch: One Blocking Call Freezes Everything
Here is the single mistake that bites almost everyone the first week. The whole Python asyncio model rests on coroutines yielding at await. If you call something that blocks the thread instead of awaiting, the chef stops chopping onions and just stares at the boiling pot. Nothing else moves. The classic version is using time.sleep() where you meant asyncio.sleep().
📄 ❌ The trap: time.sleep blocks the single thread
import asyncio
import time
start = time.perf_counter()
async def bad(name):
time.sleep(1) # WRONG: this freezes the whole event loop
print(f"{name} done at {time.perf_counter() - start:.1f}s")
async def main():
await asyncio.gather(bad("A"), bad("B"), bad("C"))
asyncio.run(main())
▶ Output
A done at 1.0s B done at 2.0s C done at 3.0s
What happened here: Three coroutines, each “sleeping” one second, took three seconds total. They ran one after another, not together. Compare that to the trace example, where three sleeps finished in the time of the longest one. The difference is a single word. time.sleep(1) tells the operating system “freeze this thread for one second,” and since asyncio lives on one thread, the entire loop freezes with it. There is no await, so there is no chance to switch.
Swap it for await asyncio.sleep(1) and all three finish in one second. The rule to memorise: inside a coroutine, never call a blocking function. Use the await-able version, and if you must call blocking code (an old library, a CPU-heavy function), push it onto a worker thread with await asyncio.to_thread(blocking_fn, args).
Tasks and Error Handling
A coroutine on its own is just a plan. Wrap it in a Task and you tell the event loop “start this now and keep track of it for me.” A Task is like handing your order to the kitchen: the work is queued and running, and you hold a ticket you can check later. The real question with many tasks is what happens when one of them fails. By default a single failure tears down the whole gather. Pass return_exceptions=True and the failures come back as values instead, so the survivors still finish.
📄 async_tasks.py: keep going when one task fails
import asyncio
async def process_item(item_id):
await asyncio.sleep(0.5) # Simulate some I/O work
if item_id == 3:
raise ValueError(f"Item {item_id} is invalid!")
return f"Processed item {item_id}"
async def main():
# create_task schedules each coroutine to run on the loop right away
tasks = [asyncio.create_task(process_item(i)) for i in range(1, 6)]
# return_exceptions=True turns a failure into a returned value
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results, 1):
if isinstance(result, Exception):
print(f" Item {i}: ERROR - {result}")
else:
print(f" Item {i}: {result}")
asyncio.run(main())
▶ Output
Item 1: Processed item 1 Item 2: Processed item 2 Item 3: ERROR - Item 3 is invalid! Item 4: Processed item 4 Item 5: Processed item 5
What happened here: Item 3 raised a ValueError, but items 1, 2, 4, and 5 still came back with their results. Because we passed return_exceptions=True, the exception object landed in the results list at position 3 instead of crashing the program, and our isinstance(result, Exception) check caught it. Without that flag, gather re-raises the first exception the moment it happens, and you lose the results from the tasks that did succeed. Use the flag when you want a best-effort batch (scrape 100 pages, keep the 97 that worked). Leave it off when any failure should stop the whole operation.
asyncio.TaskGroup is the recommended way to run a batch of tasks. You start them inside an async with asyncio.TaskGroup() as tg: block and call tg.create_task(...). When the block exits, every task is guaranteed finished, and if any task fails the group cancels the rest and raises the errors grouped together. It is cleaner than juggling gather for tasks whose lifetimes should be tied together. Reach for gather when you specifically want results in a list; reach for TaskGroup when you want structured, all-or-nothing task management.Timeouts and Cancellation
Networks hang. Servers go dark mid-response. If you wait forever, your program waits forever. Think of a customer care call: you stay on hold for a few minutes, then you hang up and try another way instead of losing your whole afternoon. The fix in code is a timeout: tell asyncio “give this operation N seconds, then cancel it and move on.” The modern tool is the asyncio.timeout() context manager.
📄 async_timeout.py: put a time limit on a slow operation
import asyncio
async def slow_operation():
await asyncio.sleep(10) # imagine a server that never answers
return "Done"
async def main():
try:
async with asyncio.timeout(2.0): # 3.11 and later
result = await slow_operation()
print(result)
except TimeoutError:
print("Operation timed out after 2 seconds!")
asyncio.run(main())
▶ Output
Operation timed out after 2 seconds!
What happened here: slow_operation wanted 10 seconds. The asyncio.timeout(2.0) block gave it 2. When the deadline passed, asyncio cancelled the awaited coroutine and the block raised TimeoutError, which we caught. On Python 3.14.6, asyncio.TimeoutError is just another name for the built-in TimeoutError (they were merged back in 3.11), so catching plain TimeoutError is the clean modern choice. If you read older tutorials you will see asyncio.wait_for(coro, timeout=2.0) wrapped in except asyncio.TimeoutError. That still works, but the context manager reads better and lets you guard several awaits with one deadline.
Build Your Own Tiny Event Loop
You truly understand the event loop the moment you build a baby version of it. Picture a board game night: each player takes one turn, passes the dice, and the game still moves forward with nobody playing two turns at once. Coroutines came from generators, and a generator can pause itself with yield and resume later, which is the whole trick. So we can write a dead-simple cooperative scheduler in about ten lines, using nothing but a list of generators and a queue. It is the same trick Python asyncio performs at industrial scale.
No asyncio import at all. Our two players, Anvi and Anvay, will each run a countdown and take turns after every number. This is, in spirit, exactly what the real event loop does, minus the timers, the OS hooks, and the years of optimisation.
📄 toy_loop.py: a cooperative scheduler from scratch
from collections import deque
def countdown(name, n):
while n > 0:
print(f"{name}: {n}")
yield # hand control back to the scheduler, like await
n -= 1
print(f"{name}: done")
def run(tasks):
ready = deque(tasks)
while ready:
gen = ready.popleft() # take the next task in line
try:
next(gen) # run it until its next yield
ready.append(gen) # not finished, send it to the back
except StopIteration:
pass # this task is finished, drop it
run([
countdown("Anvi", 3),
countdown("Anvay", 2),
])
▶ Output
Anvi: 3 Anvay: 2 Anvi: 2 Anvay: 1 Anvi: 1 Anvay: done Anvi: done
What happened here: Look at how the two countdowns interleave. Anvi prints 3, then yield hands control back to run, which puts Anvi at the back of the line and pulls Anvay forward. Anvay prints 2, yields, goes to the back, and Anvi comes around again for 2. They take turns, one yield at a time, on a single thread, with nobody preempting anybody. That yield is the ancestor of await.
The real asyncio loop adds the missing piece: instead of round-robin turns, it parks a coroutine until its specific I/O (Input/Output) is ready, then wakes exactly that one. But the bones are identical, a queue of paused tasks and a loop that resumes them. Once you see it this way, asyncio stops being magic.
Production Reality: Real Async HTTP
Sleeping is a fine teaching tool, but the reason asyncio pays your salary is real I/O: network calls, database queries, file reads. Here we fetch 50 posts from a public test API. We use httpx in async mode, because the plain requests library is synchronous and would block the loop on every call (the same trap as time.sleep). We also add a semaphore, which is just a doorman: it caps how many requests run at the same time so we do not slam the server with all 50 at once.
📄 async_http.py: fetch 50 URLs, max 10 at a time (httpx 0.28.1)
import asyncio
import httpx
import time
async def fetch_title(client, semaphore, post_id):
async with semaphore: # the doorman: only 10 inside at once
response = await client.get(
f"https://jsonplaceholder.typicode.com/posts/{post_id}"
)
data = response.json()
return data["title"]
async def main():
semaphore = asyncio.Semaphore(10)
async with httpx.AsyncClient(timeout=10) as client:
start = time.perf_counter()
tasks = [fetch_title(client, semaphore, i) for i in range(1, 51)]
titles = await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
print(f"First title: {titles[0][:40]}...")
print(f"Fetched {len(titles)} titles in {elapsed:.2f}s")
asyncio.run(main())
▶ Output (timing varies with your network)
First title: sunt aut facere repellat provident occae... Fetched 50 titles in 0.40s
What happened here: Fifty HTTP requests, finished in under half a second, on one thread. The exact number on your machine depends on your connection and how busy the server is, so do not expect 0.40s to the dot. What matters is the shape of it. If you fetched these one at a time, each request’s round-trip latency would stack up and you would wait for the sum of all fifty.
Async lets the latencies overlap, so the total is closer to “the slowest single request, repeated a few times” than “fifty requests added up.” The semaphore is the production-grade detail: without it, opening 50 connections at once can trip rate limits or exhaust sockets. asyncio.Semaphore(10) keeps a steady 10 in flight, which is polite to the server and usually just as fast.
One honest caveat on speedups. On a fast connection to a nearby server, even the sequential version of this finishes quickly, so the win can look small in a tiny benchmark. The gap explodes as latency and request count grow. That is exactly Niranjan’s 50-endpoint aggregator from the start: when each call sits on 0.5 seconds of network latency, going from one-at-a-time to all-at-once is the difference between half a minute and a couple of seconds.
Which Concurrency Model Should You Pick?
Python asyncio is not always the answer. The choice comes down to two questions: is your work waiting on I/O or burning CPU, and how many things are you doing at once?
- I/O-bound, a handful of tasks (10 to 50)? Use threading. It is the simplest mental model and the performance is fine at this scale.
- I/O-bound, lots of tasks (hundreds to thousands)? Use asyncio. It scales best because each task is cheap (no per-thread stack, no OS scheduler churn).
- CPU-bound, need real parallelism? Use multiprocessing. It runs on multiple cores and sidesteps the GIL (Global Interpreter Lock). Asyncio will not help here, since there is no waiting to overlap.
- CPU-bound and I/O-bound mixed? Combine them: multiprocessing for the heavy compute, asyncio inside each process for the I/O.
The one-line test: if your code spends most of its time waiting, asyncio (or threading) recycles that idle time. If it spends most of its time calculating, you need more cores, which means multiprocessing.
Common Misconceptions
“Calling an async function runs it.” It does not.
This is the number one beginner surprise. Calling a coroutine function does not run its body. It is like writing down a recipe: you now hold the instructions for the dish, not the dish itself. Python builds a coroutine object and hands it back, inert, waiting for you to await it. Forget the await and Python even warns you. Here is the real REPL (Read-Eval-Print Loop) session so you can see both the warning and the object you get back.
📄 Python REPL: a coroutine you forgot to await
>>> import asyncio
>>> async def fetch_data():
... return {"data": "value"}
...
>>> fetch_data() # no await: you get the OBJECT, not the result
<coroutine object fetch_data at 0x000001C391590C40>
>>> asyncio.run(fetch_data()) # now it actually runs
<stdin-3>:1: RuntimeWarning: coroutine 'fetch_data' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
{'data': 'value'}
What happened here: The bare fetch_data() call returned <coroutine object ...> instead of the dict, because calling a coroutine function only builds the coroutine. Nothing ran. Now look at where the RuntimeWarning: coroutine 'fetch_data' was never awaited lands: not on the line that created it, but one statement later. Python fires that warning when the abandoned coroutine gets garbage collected, and in the REPL the object stays alive inside the _ variable until the next result displaces it.
The label in front of the warning depends on how you launched Python (the 3.14 interactive shell numbers each input, so you might see <python-input-2> instead of <stdin-3>), the tracemalloc line is just Python offering extra debugging detail, and the hex address (0x...) will differ every run. The fix is to actually drive the coroutine, either with await fetch_data() from inside another coroutine, or with asyncio.run(fetch_data()) at the top level, which spins up the event loop, runs it to completion, and returns the dict. Notice the result prints as {'data': 'value'} with single quotes, the way Python always shows a dict.
“Asyncio makes my code parallel.” It makes it concurrent.
Asyncio runs on one thread. At any given instant exactly one piece of your Python code is executing. It feels parallel because the waiting overlaps, but two coroutines never run their actual lines at the same moment, the way two processes on two cores would. That is why asyncio is useless for CPU-heavy work: there is no idle waiting to fill, so there is nothing to switch to. Rob Pike’s quote at the top says it exactly. Concurrency is about structuring your program so tasks can make progress independently. Parallelism is about literally running them at the same time on separate hardware. Asyncio gives you the first, not the second.
Conclusion
You now have the full Python asyncio picture: one thread, one event loop, and coroutines that step aside at every await so waiting time gets recycled instead of wasted. You saw the switching with timestamps, watched a single time.sleep freeze everything, handled failures with return_exceptions=True and TaskGroup, put deadlines on slow operations with asyncio.timeout(), and even built a toy event loop from plain generators. The rule that carries all of it: never block inside a coroutine, and pick asyncio when you have many I/O operations that mostly wait.
Next up we put this to work on everyday drudgery: automating boring tasks like files, PDFs, Excel sheets, and emails. And if you want the full roadmap from beginner basics to AI/ML projects, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is asyncio in Python?
Python asyncio is the built-in library for writing concurrent code with async and await syntax. It runs a single-threaded event loop that switches between coroutines at every await point, so idle waiting on network or disk I/O is recycled to other tasks. That is how one thread can keep thousands of I/O operations in flight at once.
Is asyncio faster than threading?
For high-concurrency I/O (hundreds or thousands of connections) asyncio scales better, because each task is cheap: no per-thread stack memory and no OS scheduler overhead. For low concurrency (10 to 20 tasks) threading is simpler and performs about the same. For CPU-bound work, neither helps; use multiprocessing.
Can I use requests with asyncio?
No. The requests library is synchronous, so every call blocks the event loop and freezes every other task, the same problem as calling time.sleep inside a coroutine. Use an async HTTP client instead: httpx in async mode (httpx.AsyncClient) has nearly the same API as requests, and aiohttp is another common choice.
What is the difference between await and yield?
yield is the generator mechanism that produces values lazily and pauses a function. await is built on that same pause-and-resume machinery, but its job is to suspend a coroutine until an awaited operation finishes, letting the event loop run other coroutines in the meantime. Coroutines grew out of generators, which is why a few lines of generators can mimic a tiny event loop.
When should I not use asyncio?
Skip Python asyncio for CPU-bound work (use multiprocessing), for short simple scripts with only a couple of I/O calls (plain requests is clearer), and when all your libraries are synchronous, since wrapping blocking calls adds complexity with no real gain. Asyncio shines specifically when you have many I/O operations that spend their time waiting.
Try It Yourself
Take the production HTTP example and extend it. Fetch all 100 posts from JSONPlaceholder (https://jsonplaceholder.typicode.com/posts/{id}) with httpx.AsyncClient, keeping a Semaphore at 10. Then add error handling so a single failed request does not kill the batch (hint: return_exceptions=True or a try/except inside the fetch). Print how many succeeded, how many failed, and the total time. For a stretch goal, rewrite the task management with asyncio.TaskGroup instead of gather and notice how the cancellation behaviour changes when one task raises.
Interview Questions on Python Asyncio
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: Your async API service is fast at low traffic, but under load the latency of every endpoint spikes at the same time, even the trivial ones. What do you check first?
Something is blocking the event loop. Because asyncio runs everything on one thread, a single synchronous call (a sync database driver, the requests library, time.sleep, or a CPU-heavy loop) freezes every coroutine at once, which is exactly why unrelated endpoints slow down together. Enable asyncio debug mode (PYTHONASYNCIODEBUG=1 or asyncio.run(main(), debug=True)) to log callbacks that run too long, find the blocking call, and move it behind await asyncio.to_thread() or replace it with an async library.
Q: What exactly happens when a coroutine hits an await?
The coroutine suspends at that exact point and hands control back to the event loop, keeping its local state so it can resume later. The loop registers what the coroutine is waiting for (a timer, a socket becoming readable, another task finishing) and runs whichever other task is ready in the meantime. When the awaited operation completes, the loop marks the coroutine ready and resumes it from the line after the await. Suspension is not guaranteed to happen on every await though: if the awaited thing is already complete, execution can continue without yielding.
Q: You gather() 100 download tasks and one raises an exception, so you lose all the results. What are your options?
Three practical ones. Pass return_exceptions=True to gather so exceptions come back as values in the results list and the other 99 downloads still complete. Or wrap the body of each download coroutine in try/except so a failure returns a sentinel like None. Or, if a failure genuinely means the whole batch is invalid, switch to asyncio.TaskGroup, which cancels the remaining tasks cleanly and raises the errors together as an ExceptionGroup instead of leaving orphaned tasks running.
Q: When would you pick asyncio.TaskGroup over asyncio.gather?
Pick TaskGroup (Python 3.11 and later) when the tasks belong together as one unit of work: if any task fails, the group cancels the rest, waits for them to finish cancelling, and raises the failures as an ExceptionGroup, so nothing leaks. Pick gather when you want a simple list of results in submission order, especially best-effort batches with return_exceptions=True. In new code TaskGroup is the recommended default because its structured lifetime prevents the classic bug of fire-and-forget tasks outliving their parent.
Q: How do you run CPU-heavy work from inside async code without freezing the loop?
For blocking I/O calls from sync libraries, await asyncio.to_thread(fn, args) pushes the call onto a worker thread and keeps the loop responsive. For genuinely CPU-bound work, a thread is usually not enough because the GIL still serialises Python bytecode, so hand it to a process pool: loop.run_in_executor(ProcessPoolExecutor(), fn, args) runs it on another core. The rule is the same either way: the event loop thread must only ever wait, never grind.
Q: What happens inside a task when it gets cancelled, for example by asyncio.timeout()?
asyncio.CancelledError is raised inside the coroutine at the await point where it is currently suspended. You can catch it to run cleanup (close a connection, roll back a transaction), but you should re-raise it or let it propagate, because swallowing it makes the task uncancellable and can hang timeouts. asyncio.timeout() is built on exactly this mechanism: it cancels the task at the deadline, then converts the CancelledError into a TimeoutError at the context manager boundary.
Go deeper: when you outgrow this post, Python asyncio documentation is the next stop.
Related Posts
Previous: Python: Multiprocessing for Parallel CPU-bound Tasks
Next: Python: Automating Boring Tasks (Files, PDFs, Excel, Emails)
Series Home: Python + AI/ML Tutorial Series

No comment