Python

Python Asyncio👨‍💻

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.

Key Takeaways

  • 1Coroutines defined with `async def` do not execute when called — they return a coroutine object that must be awaited or scheduled as a Task
  • 2`asyncio.run()` is the single entry point: it creates the event loop, runs your top-level coroutine, and tears everything down cleanly
  • 3`TaskGroup` (Python 3.11+) replaces most `gather()` usage — it cancels sibling tasks when one fails and collects errors into an ExceptionGroup, so you never silently lose failures
  • 4`asyncio.Semaphore` is how you rate-limit concurrent operations — essential when hitting external APIs or databases that have connection limits
  • 5Async generators (`async for`) let you process streaming data without buffering everything into memory — useful for database cursors, paginated APIs, and SSE streams
  • 6Blocking calls like `time.sleep()`, `requests.get()`, or CPU-heavy computation freeze the entire event loop — use `run_in_executor()` to push them to a thread pool

Master python asyncio

Take the Python Concurrency course with hands-on lessons and challenges.

Examples

Concurrent HTTP requests with aiohttp

python

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.

Rate-limited concurrency with Semaphore

python

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.

TaskGroup vs gather — error handling difference

python

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 generator for paginated API consumption

python

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.

Async database queries with connection pool

python

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.

Running blocking code without freezing the loop

python

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.

Common Mistakes

Mistake:

Calling `time.sleep()` or synchronous `requests.get()` inside a coroutine — this blocks the entire event loop and freezes all other tasks

Fix:

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.

Mistake:

Calling a coroutine without `await` — `fetch_data()` returns a coroutine object but never executes it, and Python only raises a RuntimeWarning

Fix:

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.

Mistake:

Using `gather(return_exceptions=True)` and forgetting to check each result for exceptions — errors silently pass through as list elements

Fix:

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.

Mistake:

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

Fix:

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.

Best Practices

  • Default to TaskGroup over gather() — it handles errors correctly, cancels sibling tasks on failure, and prevents orphan tasks. Only reach for gather() when you specifically need return_exceptions=True with partial-failure tolerance.
  • Always use Semaphore when fanning out to external services. Spawning 10,000 tasks that all hit the same API or database will exhaust connections and trigger rate limits. A Semaphore with a sensible limit (10-50 for HTTP, match your pool size for databases) keeps things stable.
  • Use `asyncio.run()` as your single entry point and avoid manually managing the event loop. Calling `get_event_loop()` directly is legacy code from Python 3.6 — modern asyncio does not need it.
  • Push blocking or CPU-bound work to an executor. Any synchronous call that takes more than a few milliseconds should go through `loop.run_in_executor()` so it does not stall other coroutines. File I/O, image processing, and hashing are common offenders.
  • Prefer async context managers (`async with`) for resource lifecycle management — database connections, HTTP sessions, file handles. This ensures cleanup happens even when tasks are cancelled.
  • Structure your async code top-down: `asyncio.run()` at the top, TaskGroups for concurrent sections, and keep individual coroutines small and focused on one I/O operation. Deep nesting of TaskGroups inside TaskGroups usually means you need to rethink the design.

Summary

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.

Practice Python with hands-on challenges

Learn python asyncio hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master Python with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.