Asyncio is Python's built-in framework for writing concurrent I/O-bound code on a single thread. If your application talks to databases, makes HTTP requests, reads files, or handles WebSocket connections, asyncio lets you overlap those waits instead of sitting idle. It is not about parallelism — it is about not wasting time blocking on I/O.
The mental model is straightforward: when a coroutine hits an await, it suspends and hands control back to the event loop, which can run other coroutines while the I/O completes. No threads, no locks, no shared-state headaches. Python 3.11 introduced TaskGroups and except*, which finally brought structured concurrency to the language — making it much harder to accidentally leak tasks or silently swallow errors. If you are still reaching for gather() by default, it is time to upgrade your patterns.
Master python asyncio
Take the Python Concurrency course with hands-on lessons and challenges.
This is the bread and butter of asyncio. Three HTTP requests fire concurrently inside a TaskGroup, so total wall-clock time is roughly the slowest request, not all three added together. The session is shared across requests for connection pooling.
Without a Semaphore, spawning 200 tasks would hammer the server with 200 simultaneous connections. The Semaphore gates entry so only 10 coroutines are inside the HTTP call at any time. The rest wait their turn without blocking the event loop.
With gather(return_exceptions=True), exceptions hide as list elements — you have to manually check every result. With TaskGroup, failure is loud: remaining tasks get cancelled and you get a typed ExceptionGroup. This is why TaskGroup is the better default for anything beyond fire-and-forget fan-out.
Async generators let you lazily stream data from paginated APIs. Each page is fetched only when the consumer asks for more items, so memory stays flat regardless of total result count. This pattern works equally well for database cursors and SSE event streams.
This shows asyncio in a real backend context: asyncpg provides an async connection pool, and we use TaskGroup to fetch multiple users concurrently while each user's queries also run in parallel via gather. The pool limits total database connections automatically.
File hashing is CPU-bound and synchronous — calling it directly would freeze every other coroutine. run_in_executor offloads it to a thread pool so the event loop stays responsive. This same technique works for any blocking library that does not have an async equivalent.
Calling `time.sleep()` or synchronous `requests.get()` inside a coroutine — this blocks the entire event loop and freezes all other tasks
Use `await asyncio.sleep()` for delays, `aiohttp` or `httpx` for HTTP requests. If you must call blocking code, wrap it with `await loop.run_in_executor(None, blocking_fn, args)` to push it to a thread pool.
Calling a coroutine without `await` — `fetch_data()` returns a coroutine object but never executes it, and Python only raises a RuntimeWarning
Always `await` coroutine calls or schedule them with `create_task()`. Enable asyncio debug mode (`asyncio.run(main(), debug=True)`) during development to turn these warnings into loud errors.
Using `gather(return_exceptions=True)` and forgetting to check each result for exceptions — errors silently pass through as list elements
Prefer `TaskGroup` which forces you to handle errors via `except*`. If you must use `gather`, always loop through results and explicitly check `isinstance(result, Exception)` for each one.
Creating tasks with `asyncio.create_task()` outside of an `async with` scope and never awaiting them — orphan tasks may get garbage collected before they finish
Use a `TaskGroup` to scope task lifetimes. Every task created inside `async with asyncio.TaskGroup() as tg:` is guaranteed to complete (or be cancelled) before the block exits. No orphans possible.
Asyncio lets you write concurrent I/O-bound Python without threads or locks. Coroutines suspend at `await` points, and the event loop runs other work while I/O completes. Use `asyncio.run()` to start, TaskGroup (3.11+) for structured concurrency with proper error handling, and Semaphore to rate-limit fan-out operations. Async generators handle streaming data. Anything blocking goes to `run_in_executor()`. The shift from gather() to TaskGroup is not optional — structured concurrency eliminates the entire class of orphan-task and silent-failure bugs that plague older async code.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.