Python’s asyncio library has been part of the standard library since 3.4, reached a stable API in 3.7, and is now the foundation of Python’s async ecosystem. Despite being widely used, asyncio is also frequently misused — developers write async code that blocks the event loop, loses concurrency, or adds overhead with no benefit. This guide focuses on the patterns that matter and the mistakes to avoid.
The Fundamentals
The event loop model: asyncio uses a single-threaded event loop that runs coroutines. The critical insight: a coroutine only yields control to the event loop when it encounters an `await` expression. Between `await` points, execution is synchronous and blocks everything else. This is cooperative multitasking — not preemptive threading. What `async def` and `await` mean: `async def` defines a coroutine function — calling it returns a coroutine object, it does not execute. The coroutine executes when you `await` it (inside another coroutine) or schedule it with `asyncio.run()` or `asyncio.create_task()`. `await` suspends the current coroutine and yields control to the event loop, which can run other coroutines while waiting. The key distinction: I/O-bound vs CPU-bound work. asyncio excels at I/O-bound concurrency — making 100 HTTP requests, reading from 50 files, querying 10 database connections simultaneously. It does nothing useful for CPU-bound work (computation, data processing, image encoding) — for those, use `multiprocessing` or `concurrent.futures.ProcessPoolExecutor`. Running multiple coroutines concurrently: the most important pattern — `asyncio.gather()` takes multiple coroutines and runs them concurrently. Without gather (running them sequentially with `await`), you get no concurrency benefit:
“`python
# Sequential (bad for I/O) — total time = sum of all times
result1 = await fetch_user(1)
result2 = await fetch_user(2)
# Concurrent (correct for I/O) — total time = max of individual times
result1, result2 = await asyncio.gather(fetch_user(1), fetch_user(2))
“`
`asyncio.create_task()`: creates a Task from a coroutine — the task starts running immediately (as soon as the event loop has control). Use this when you need to start a coroutine and do other things while it runs, then collect results later. `asyncio.TaskGroup` (Python 3.11+): the preferred way to manage groups of tasks with structured concurrency semantics — all tasks in the group are cancelled if one raises an exception.
Common Mistakes and How to Avoid Them
Blocking the event loop: calling any blocking operation from a coroutine blocks the entire event loop — all other coroutines freeze until it returns. Common offenders: `time.sleep()` (use `await asyncio.sleep()`), `requests.get()` (use `httpx` or `aiohttp`), `open()` and file reads (use `aiofiles`), `subprocess.run()` (use `asyncio.create_subprocess_exec()`). Running blocking code when unavoidable: use `asyncio.get_event_loop().run_in_executor(None, blocking_function, args)` to run blocking code in a thread pool without blocking the event loop. Mixing sync and async incorrectly: you cannot `await` inside a regular function. If you have a sync function that needs to call async code, use `asyncio.run()` (only at the top level — cannot call `asyncio.run()` from inside a running event loop). The httpx pattern (the correct async HTTP client):
“`python
import httpx
import asyncio
async def fetch_many(urls: list[str]) -> list[str]:
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [r.text for r in responses]
“`
The async context manager pattern (`async with`): many async resources (database connections, HTTP clients, file handles) require async context managers — the `async with` block calls `__aenter__` and `__aexit__` as coroutines. Always use them; they ensure proper resource cleanup. Debugging asyncio: run with `PYTHONASYNCIODEBUG=1` to enable the slow callback detection (warns when a callback blocks the event loop longer than 100ms). Use `asyncio.run(main(), debug=True)` for development.




