Async Programming with asyncio
Many programs spend most of their time waiting — for web requests, database queries or files. asyncio lets one Python program do other work during those waits, so it can handle hundreds of network operations concurrently on a single thread using async and await.
Coroutines: async def and await
import asyncio async def make_tea(): print("boiling water…") await asyncio.sleep(0.2) print("tea is ready") return "🍵" async def main(): drink = await make_tea() print("got", drink) asyncio.run(main())
boiling water… tea is ready got 🍵
async defdefines a coroutine function. Calling it doesn't run it; it creates a coroutine object.awaitruns another coroutine and pauses the current one until it finishes — while paused, other tasks can run.asyncio.run(main())starts the event loop and runs the top-level coroutine. Call it once, at your program's entry point.
Note: asyncio.sleep waits without blocking; the ordinary time.sleep would freeze everything.
Running tasks concurrently
Awaiting coroutines one after another runs them in sequence. asyncio.gather runs them concurrently:
import asyncio import time async def fetch(name, seconds): await asyncio.sleep(seconds) return f"{name} done" async def main(): start = time.perf_counter() await fetch("a", 0.2) await fetch("b", 0.2) print(f"sequential: ~{round(time.perf_counter() - start, 1)}s") start = time.perf_counter() results = await asyncio.gather(fetch("a", 0.2), fetch("b", 0.2), fetch("c", 0.2)) print(results, f"concurrent: ~{round(time.perf_counter() - start, 1)}s") asyncio.run(main())
sequential: ~0.4s ['a done', 'b done', 'c done'] concurrent: ~0.2s
Three 0.2-second waits overlap and finish in 0.2 seconds total. With real network requests, this is the difference between a scraper taking minutes or seconds.
Task groups
asyncio.TaskGroup is the modern, safer way to run several tasks: if one fails, the others are cancelled and the errors are reported together:
import asyncio async def download(page): await asyncio.sleep(0.05 * page) return f"page {page}: {page * 100} bytes" async def main(): async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(download(p)) for p in range(1, 4)] for t in tasks: print(t.result()) asyncio.run(main())
page 1: 100 bytes page 2: 200 bytes page 3: 300 bytes
Timeouts
import asyncio async def slow_query(): await asyncio.sleep(5) return "rows" async def main(): try: async with asyncio.timeout(0.1): await slow_query() except TimeoutError: print("query took too long — cancelled") asyncio.run(main())
query took too long — cancelled
Limiting concurrency
Launching thousands of requests at once can overwhelm a server or get you rate-limited. A Semaphore caps how many run at the same time:
import asyncio active = 0 peak = 0 async def worker(i, limit): global active, peak async with limit: active += 1 peak = max(peak, active) await asyncio.sleep(0.02) active -= 1 async def main(): limit = asyncio.Semaphore(3) await asyncio.gather(*(worker(i, limit) for i in range(20))) print("max running at once:", peak) asyncio.run(main())
max running at once: 3
Async iteration and context managers
import asyncio async def ticker(n): for i in range(n): await asyncio.sleep(0.01) yield i async def main(): async for tick in ticker(3): print("tick", tick) squares = [t * t async for t in ticker(4)] print(squares) asyncio.run(main())
tick 0 tick 1 tick 2 [0, 1, 4, 9]
async for loops over async generators — useful for streaming results or paginated APIs. Similarly, async with manages resources that need asynchronous setup and cleanup, like network connections.
Async in practice
To benefit from asyncio, the libraries you call must be async too. Common choices:
| Task | Async libraries |
|---|---|
| HTTP requests | httpx, aiohttp |
| Web frameworks | FastAPI, Starlette, Django (async views) |
| Databases | asyncpg, SQLAlchemy's async mode |
import asyncio import httpx async def main(): urls = [f"https://jsonplaceholder.typicode.com/posts/{i}" for i in range(1, 6)] async with httpx.AsyncClient(timeout=10) as client: responses = await asyncio.gather(*(client.get(u) for u in urls)) for r in responses: print(r.json()["title"][:40]) asyncio.run(main())
When to use asyncio
- Good fit: lots of waiting on I/O — web servers, scrapers, chat bots, API clients calling many endpoints.
- Poor fit: CPU-heavy work like image processing or number crunching. Async doesn't make computation faster; use
multiprocessingorconcurrent.futures.ProcessPoolExecutorfor that. - Mixing in blocking code: wrap it with
await asyncio.to_thread(func, *args)so it doesn't freeze the event loop.
Try it yourself
await main(). In a normal script, call asyncio.run(main()) instead.