Python Advance 📂 Async Await · 1 of 1 51 min read

Python Async Await Tutorial with Diagrams & Real Examples (2026)

Master Python's async / await from first principles. Learn what coroutines really are, how the event loop schedules them, and why the same one-thread program can be 10× faster at network work. Includes side-by-side sync vs concurrent benchmarks, a real weather-aggregator example with aiohttp, timeout & cancellation patterns, TaskGroup, semaphore-bounded concurrency, and eight golden rules

Section 01

The Story That Explains Async / Await

The Coffee Shop Barista
Imagine a barista taking one order at a time. A customer orders a latte. The barista grinds beans, pulls the espresso shot (30 seconds), steams the milk (40 seconds), pours, hands it over — then looks at the next customer. Behind them, ten people wait while the barista simply stands and watches the espresso machine run.

Now imagine a smarter barista. Order taken → espresso started → while it runs, the barista takes the next order, starts another shot, steams milk for order #1, checks on order #2's shot. The barista never idles. Same one person. Same two hands. But throughput triples because they never wait on the machine.

That second barista is async / await. Python doesn't get more threads or more cores — it just stops standing around while waiting for slow things (network, disk, timers).

async / await is Python's way of writing code that pauses cleanly at slow I/O points and lets other work run during the wait. It is single-threaded, cooperative, and dramatically faster for anything that spends most of its time waiting on the network, a database, files, or timers.

🧠
The Core Insight

async / await does not make CPU work faster. It makes waiting faster — by removing the wait entirely and doing something else in that gap. Use it when your program spends time waiting on external things, not when it spends time computing.


Section 02

The Problem — Why Sync Code Is Slow at I/O

Consider fetching three URLs. Each one takes ~1 second because the network is slow. Synchronous code stops the whole program during every wait.

⏳ Synchronous (Blocking)
StepTime
Fetch URL 10.0s → 1.0s
Fetch URL 21.0s → 2.0s
Fetch URL 32.0s → 3.0s
Total3.0 seconds
⚡ Asynchronous (Concurrent)
StepTime
Start URL 10.0s (returns instantly)
Start URL 20.0s (returns instantly)
Start URL 30.0s (returns instantly)
All finish~1.0 second
📈 Diagram — Sync vs Async Timeline (3 network calls, 1s each)
Task busy / waiting Task A Task B Task C
SYNCHRONOUS — total 3.0s Fetch A (waits) Fetch B (waits) Fetch C (waits) ⏳ CPU idle 100% of this time — just waiting on the network ASYNCHRONOUS — total 1.0s Fetch A Fetch B Fetch C 3× faster 0s 1s 2s 3s

The three async tasks all hit their await point almost immediately — the event loop parks them together, and all three timers expire at the same moment.

⚠️
The Waste of Blocking I/O

In the synchronous version, your CPU sits at ~0% usage for 3 full seconds while it waits for the network. That's wasted machine time. Async reclaims it — and for I/O-heavy programs (web scrapers, APIs, chat bots, database workers), the speedup is often 10× to 100×.


Section 03

The Three Building Blocks

🔁
1 — Coroutine
async def function
A function defined with async def. Calling it does NOT run it — it returns a coroutine object, a paused, resumable computation. Nothing happens until the event loop schedules it.
2 — await
pause here, let others run
Inside a coroutine, await x means: "suspend me, hand control back to the event loop until x finishes." Only usable inside async def functions.
🔄
3 — Event Loop
asyncio.run(...)
The scheduler that runs coroutines, tracks which ones are waiting on I/O, and resumes them the moment their data arrives. You start it once with asyncio.run().

Your First Async Function

import asyncio

async def greet(name):
    print(f"Hello {name}, starting...")
    await asyncio.sleep(1)          # pause 1s WITHOUT blocking
    print(f"Goodbye {name}")

# Calling greet() only creates a coroutine — nothing runs yet
# asyncio.run() starts the event loop and executes it
asyncio.run(greet("Mohit"))
OUTPUT
Hello Mohit, starting... (pauses 1 second — but CPU is FREE during this pause) Goodbye Mohit
🔁 Diagram — Coroutine State Machine
CREATED coro = fn() await / run RUNNING executing lines hits await SUSPENDED paused, I/O pending I/O ready — resume return DONE result available A coroutine may bounce RUNNING ⇆ SUSPENDED many times before reaching DONE. Each await = one round-trip through SUSPENDED. Other coroutines run in the gap.
💡
sleep() vs asyncio.sleep()

time.sleep(1) blocks the entire thread — even in async code nothing else can run. await asyncio.sleep(1) yields for 1 second, allowing other coroutines to run during the pause. Always use the async version inside async code.


Section 04

Visual Diagram — Inside the Event Loop

🔄 Diagram — The Event Loop Scheduling Four Coroutines
EVENT LOOP picks ready coro runs → yields Coroutine A running • on CPU yields on await Coroutine B waiting on socket parked Coroutine C ready to resume I/O arrived Coroutine D waiting on timer parked

Only ONE coroutine runs on the CPU at any instant (solid arrow into loop). The others sit in "suspended" (dashed arrows). When their I/O finishes, the OS notifies the loop and they become "ready" — and take their turn on the CPU.

Step-by-Step: What Happens on Every Tick

01
You call asyncio.run(main())
Python creates the event loop, schedules the main() coroutine, and starts spinning the loop. This is the ONLY entry point you should use in a script.
02
Loop picks a ready coroutine and runs it
Runs it synchronously until the coroutine hits an await. That's the cooperative handover point — nothing preempts a coroutine mid-CPU-work.
03
await hit → coroutine suspended
The coroutine registers "wake me when this network read / timer / DB reply arrives" and returns control to the loop. Its stack frame is preserved.
04
Loop picks the NEXT ready coroutine
While coroutine A waits, the loop runs coroutine B, C, D — anything that has work to do. This is how one thread juggles thousands of connections.
05
I/O event arrives → coroutine resumes
The OS tells Python "socket 42 has data". The loop wakes the coroutine parked on that socket, continues it from exactly where it left off.
06
All coroutines finished → loop exits
When every scheduled task is done, asyncio.run() tears down the loop and returns the result of your main coroutine.
📈
Single Thread, Many Tasks

All of this happens on one OS thread. There are no locks, no race conditions between coroutines at the language level, no GIL contention. That simplicity is why async has taken over networked Python — from FastAPI to Discord bots to database drivers.


Section 05

Running Things Concurrently — asyncio.gather

A single await on its own doesn't buy you anything — it just waits. The speedup comes from running multiple coroutines at once with asyncio.gather().

Sequential vs Concurrent — the Same Task, Two Ways

import asyncio, time

async def fetch(name, delay):
    print(f"→ start {name}")
    await asyncio.sleep(delay)      # simulate network
    print(f"✓ done  {name}")
    return f"{name}-result"

async def sequential():
    t0 = time.perf_counter()
    a = await fetch("A", 1)     # wait for A
    b = await fetch("B", 1)     # then wait for B
    c = await fetch("C", 1)     # then wait for C
    print(f"Sequential: {time.perf_counter()-t0:.2f}s")

async def concurrent():
    t0 = time.perf_counter()
    a, b, c = await asyncio.gather(
        fetch("A", 1),
        fetch("B", 1),
        fetch("C", 1),
    )
    print(f"Concurrent: {time.perf_counter()-t0:.2f}s")

asyncio.run(sequential())
asyncio.run(concurrent())
OUTPUT
→ start A ✓ done A → start B ✓ done B → start C ✓ done C Sequential: 3.01s → start A → start B → start C ✓ done A ✓ done B ✓ done C Concurrent: 1.01s ← 3× faster on the same one thread
📈 Diagram — asyncio.gather() Execution Trace
on CPU (running) suspended (awaiting I/O)
Task A await sleep(1) — suspended Task B await sleep(1) — suspended Task C await sleep(1) — suspended t=0.00 gather starts t=1.00 all resume t=1.01 Total wall time = 1.01s (not 3s!)

Reading left-to-right: each task briefly runs (orange), immediately hits await, and enters SUSPENDED (faint bar). The event loop juggles all three during the 1-second window. When the timers fire, each task briefly runs again to finish.

🏆
Where the Speedup Came From

All three fetch() calls hit their await asyncio.sleep(1) almost simultaneously. The event loop then had three coroutines all waiting for the same 1-second timer. When the timer fired, all three resumed one after another. Total wall time = the longest single wait, not the sum.


Section 06

Tasks — Fire-and-Continue Concurrency

asyncio.gather() waits for a fixed set of coroutines to all finish. Sometimes you want to start a coroutine, continue your own work, and collect the result later. That's what a Task is — a coroutine wrapped in a scheduled unit of work.

📌 Task Lifecycle
Create
task = asyncio.create_task(coro()) — schedules the coroutine on the loop immediately.
Runs
The task runs concurrently with your current coroutine at every await point.
Collect
result = await task — pause until the task finishes and get its return value.
Cancel
task.cancel() — request cancellation. A CancelledError is raised inside the task at its next await.
import asyncio

async def slow_price_lookup(symbol):
    await asyncio.sleep(2)      # pretend this is an API call
    return {"symbol": symbol, "price": 142.35}

async def main():
    # Fire the task — it starts running NOW, in the background
    task = asyncio.create_task(slow_price_lookup("AAPL"))

    # Meanwhile, do other work — this takes ~1 second
    print("doing local calculations...")
    await asyncio.sleep(1)
    print("calculations done, now waiting on API...")

    # Now collect the result. Only ~1 more second of waiting (not 2!)
    result = await task
    print(f"Got: {result}")

asyncio.run(main())
OUTPUT
doing local calculations... calculations done, now waiting on API... Got: {'symbol': 'AAPL', 'price': 142.35} Total wall time: ~2 seconds (not 3)
📌 Diagram — create_task() vs Sequential await
Sequential await API (2s) local calc (1s) = 3s total create_task API runs in background local calc → await task → 2s total 0s 1s 2s 3s 1s saved

Sequential await forces you to wait for the API before starting local work. create_task starts the API immediately, and your local work overlaps with the network wait.


Section 07

Practical Example — Fetching Many URLs with aiohttp

This is where async really shines. aiohttp is the async equivalent of requests. Ten URLs that would take 10 seconds sequentially can be fetched in ~1 second concurrently.

# pip install aiohttp
import asyncio, aiohttp, time

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

async def fetch_url(session, url):
    async with session.get(url) as resp:
        return resp.status, len(await resp.text())

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, u) for u in urls]
        return await asyncio.gather(*tasks)

t0 = time.perf_counter()
results = asyncio.run(fetch_all(URLS))
print(f"Fetched {len(results)} URLs in {time.perf_counter()-t0:.2f}s")
for status, size in results:
    print(f"  status={status}  bytes={size}")
OUTPUT
Fetched 5 URLs in 1.14s ← would be 5+ seconds with requests status=200 bytes=421 status=200 bytes=421 status=200 bytes=421 status=200 bytes=421 status=200 bytes=421
🛡️
Always Reuse the Session

Create one aiohttp.ClientSession and pass it to every request. Creating a session per request destroys the entire benefit — you throw away connection pooling, DNS caching, and TCP keep-alive. Same rule applies to httpx.AsyncClient.


Section 08

Timeouts & Cancellation

Network calls hang. Databases stall. You need a way to say "give up if this takes too long." asyncio.wait_for() and asyncio.timeout() handle this cleanly.

import asyncio

async def slow_task():
    await asyncio.sleep(10)
    return "done"

async def main():
    # Modern style — Python 3.11+
    try:
        async with asyncio.timeout(2):
            result = await slow_task()
    except TimeoutError:
        print("gave up after 2 seconds")

    # Classic style — works on all versions
    try:
        result = await asyncio.wait_for(slow_task(), timeout=2)
    except asyncio.TimeoutError:
        print("also gave up after 2 seconds")

asyncio.run(main())
OUTPUT
gave up after 2 seconds also gave up after 2 seconds

Manually Cancelling a Task

import asyncio

async def worker(n):
    try:
        while True:
            print(f"worker {n} tick")
            await asyncio.sleep(0.5)
    except asyncio.CancelledError:
        print(f"worker {n} shutting down cleanly")
        raise                        # re-raise so cancellation propagates

async def main():
    t = asyncio.create_task(worker(1))
    await asyncio.sleep(2)
    t.cancel()                     # request cancellation
    try:
        await t
    except asyncio.CancelledError:
        pass

asyncio.run(main())

Section 09

Common Pitfalls (and How to Avoid Them)

MistakeWhat HappensFix
time.sleep(2) inside async Freezes the entire event loop for 2s Use await asyncio.sleep(2)
Calling requests.get() in async Blocks the loop — kills concurrency Use aiohttp or httpx.AsyncClient
Forgetting await Coroutine never runs, silent bug Any async def call needs an await
CPU-heavy math inside async Starves other coroutines Offload with asyncio.to_thread(fn) or ProcessPoolExecutor
Multiple asyncio.run() calls Creates a new loop each time — state lost Call asyncio.run() ONCE at the top level
Not using a session for HTTP No pooling, 10× slower than necessary One ClientSession, reuse for every request
Unawaited task discarded Task garbage-collected mid-run Store references: tasks.append(create_task(...))
⚠️
The Golden Sin — Blocking Inside Async

A single time.sleep(), requests.get(), or long-running pandas operation inside an async def will freeze every other coroutine until it finishes. Async is cooperative — everyone must yield. When you MUST call a synchronous library, wrap it: await asyncio.to_thread(blocking_fn, args).


Section 10

async / await vs Threads vs Processes

ApproachBest ForConcurrency ModelOverhead per Task
async / await I/O-bound (network, DB, files) Cooperative, 1 thread ~KB — millions possible
Threads I/O-bound legacy code Preemptive, OS threads (GIL) ~MB — thousands limit
Multiprocessing CPU-bound (math, ML, image) Parallel, separate processes Heavy — one per CPU core
🎯
Rule of Thumb

Is your program waiting on external things? → async / await.
Is it crunching numbers or images? → multiprocessing.
Are you calling a mix of blocking libraries you can't replace? → threads or asyncio.to_thread().


Section 11

Real-World Example — Weather Dashboard Aggregator

Let's build something concrete: fetch current weather for 5 cities from a public API in parallel, handle failures gracefully, apply a timeout, and print a formatted summary.

import asyncio, aiohttp, time

CITIES = [
    ("Mumbai",     19.0760, 72.8777),
    ("New York",   40.7128, -74.0060),
    ("London",     51.5074, -0.1278),
    ("Tokyo",      35.6762, 139.6503),
    ("Sydney",    -33.8688, 151.2093),
]

API = "https://api.open-meteo.com/v1/forecast"

async def fetch_weather(session, city, lat, lon):
    params = {"latitude": lat, "longitude": lon, "current_weather": True}
    try:
        async with asyncio.timeout(5):
            async with session.get(API, params=params) as r:
                data = await r.json()
                cw = data["current_weather"]
                return city, cw["temperature"], cw["windspeed"], None
    except Exception as e:
        return city, None, None, str(e)

async def main():
    t0 = time.perf_counter()
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(
            *[fetch_weather(session, c, lat, lon) for c, lat, lon in CITIES]
        )

    print(f"\nWeather report — fetched in {time.perf_counter()-t0:.2f}s")
    print("-" * 50)
    for city, temp, wind, err in results:
        if err:
            print(f"{city:12s}  ERROR: {err}")
        else:
            print(f"{city:12s}  {temp:5.1f}°C   wind {wind:.1f} km/h")

asyncio.run(main())
OUTPUT
Weather report — fetched in 0.87s -------------------------------------------------- Mumbai 28.4°C wind 12.3 km/h New York 15.1°C wind 8.7 km/h London 11.6°C wind 6.2 km/h Tokyo 22.8°C wind 10.1 km/h Sydney 19.4°C wind 15.5 km/h
🏆
Five HTTP Calls, Under One Second

Sequentially, this would take ~5 seconds. With async gather + one shared session, it's ~1 second — bounded by the single slowest call, not their sum. Add 50 cities and it barely gets slower.


Section 12

Limiting Concurrency — Don't DDoS Your Own API

Firing 10,000 requests at once is easy in async — and a great way to get rate-limited or crash the server. Use asyncio.Semaphore to cap in-flight tasks.

import asyncio, aiohttp

async def fetch_one(sem, session, url):
    async with sem:                          # at most N in flight
        async with session.get(url) as r:
            return await r.text()

async def fetch_many(urls, max_concurrent=10):
    sem = asyncio.Semaphore(max_concurrent)
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(
            *[fetch_one(sem, session, u) for u in urls]
        )

# Even if urls has 10,000 entries, only 10 requests run at any moment
urls = [f"https://httpbin.org/anything/{i}" for i in range(10_000)]
results = asyncio.run(fetch_many(urls, max_concurrent=20))
🔒 Diagram — Semaphore Gate (max_concurrent = 3)
WAITING QUEUE T7 T6 T5 T4 SEMAPHORE (3 slots) T1 T2 T3 in flight (running) gate blocks any 4th task finished COMPLETED D3 D2 D1 When a slot frees, the next waiting task acquires it and enters the semaphore. You can schedule 10,000 tasks — but only 3 hammer the server at any moment.

The async with sem: block acquires one slot; releasing it happens automatically when the block exits. This is how you stay a good API citizen at scale.


Section 13

When to Use async / await

Web Servers & APIs
FastAPI, Starlette, aiohttp — handle thousands of concurrent connections on a single worker. Standard for modern Python web.
fastapi, uvicorn, starlette
API Aggregation / Scraping
Fetch 100 endpoints, combine results. The difference between 100s sequential and 2s concurrent is transformative.
aiohttp, httpx, playwright
Chat Bots & Real-Time
Discord bots, WebSocket clients, MQTT — thousands of long-lived connections idle 99% of the time.
discord.py, websockets
CPU-Heavy Math
Image processing, NumPy loops, ML training — one thread, no parallelism gain. Use multiprocessing or joblib.
numpy, pandas, scikit-learn
Small Simple Scripts
If you're fetching 2 URLs once a day, the extra complexity isn't worth it. requests is fine.
one-off automation
Blocking-Library-Heavy Code
If every operation you need requires a synchronous C library, threads may be cleaner than wrapping everything in to_thread().
legacy DB drivers, imaging libs

Section 14

Modern Pattern — asyncio.TaskGroup (Python 3.11+)

TaskGroup is the recommended replacement for gather when you want clean error handling: if any child task fails, the group cancels the rest and raises a single grouped exception.

import asyncio

async def work(name, delay, fail=False):
    await asyncio.sleep(delay)
    if fail:
        raise RuntimeError(f"{name} exploded")
    return f"{name} ok"

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            t1 = tg.create_task(work("A", 1))
            t2 = tg.create_task(work("B", 2, fail=True))
            t3 = tg.create_task(work("C", 3))
    except* RuntimeError as eg:      # exception group syntax
        for e in eg.exceptions:
            print(f"caught: {e}")

asyncio.run(main())
OUTPUT
caught: B exploded (tasks A and C were auto-cancelled — no zombie coroutines)

Section 15

Golden Rules

⚡ async / await — Non-Negotiable Rules
1
Call asyncio.run(main()) exactly once at the top of your program. Never nest it, never call it inside another coroutine.
2
Never call blocking code inside async def. No time.sleep, no requests.get, no long pandas ops. If you must, wrap it: await asyncio.to_thread(blocking_fn, args).
3
Use asyncio.gather() to run known coroutines concurrently. Use asyncio.create_task() to fire-and-continue. Prefer asyncio.TaskGroup on Python 3.11+ for clean error handling.
4
Always reuse HTTP sessions — one aiohttp.ClientSession (or httpx.AsyncClient) per program, not per request. Losing connection pooling erases most of the speedup.
5
Always add timeouts to external calls. A hung connection with no timeout is a coroutine that will never exit. asyncio.timeout(seconds) is your friend.
6
When firing thousands of tasks, bound them with a Semaphore. Async makes DDoSing your own API effortless — set a limit like 20–50 in-flight requests.
7
Store references to tasks you create with create_task(). Unreferenced tasks can be garbage-collected mid-run and silently disappear.
8
async is for I/O-bound work. For CPU-heavy math (NumPy, image processing, training), use multiprocessing or ProcessPoolExecutor — async won't help there, it's still one thread.
You have completed Async Await. View all sections →