Python's async/await syntax trips up a lot of developers because it looks similar to async patterns in JavaScript but works differently. Once you understand the event loop model, everything clicks.
The Core Problem: Waiting for I/O
Synchronous Python code blocks. When you call requests.get("https://api.example.com"), the entire thread stops and waits for the network response — which might take 200ms. During that time, the thread cannot do anything else.
With async I/O, instead of blocking the thread, you register the I/O operation with an event loop and yield control back. The event loop can run other coroutines while waiting for the network response to come back.
import asyncio
import httpx
async def fetch_user(user_id: int):
async with httpx.AsyncClient() as client:
response = await client.get(f"/api/users/{user_id}")
return response.json()
async def main():
# These run concurrently — total time ~ max of individual times
users = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3),
)What the Event Loop Actually Does
The event loop is a loop that continuously:
1. Checks if any pending I/O operations have completed
2. Runs the coroutines that were waiting on completed operations
3. Schedules new I/O operations
4. Repeats
It runs in a single thread. This is the key difference from threading. There is no parallelism — coroutines take turns running, yielding at each await point.
When Async Helps and When It Does Not
Async I/O is valuable when your code spends significant time waiting for external resources (databases, APIs, file I/O). FastAPI with asyncpg on a database-heavy web service handles dramatically more concurrent requests than a synchronous equivalent.
Async I/O does not help with CPU-bound work. If you are doing image processing, numerical computation, or any task that keeps the CPU busy, async only adds complexity. Use multiprocessing or offload to a worker queue.
# CPU-bound: async does nothing useful here
async def compute_something_heavy(data):
result = await asyncio.to_thread(heavy_cpu_function, data)
return resultasyncio.to_thread runs blocking functions in a thread pool without blocking the event loop — the right pattern for mixing CPU work with an async application.