The asyncio docs lost me on my first read. "Coroutines are awaitables that wrap a Future returned by an event loop scheduling a Task." I read that paragraph four times and still could not have written async def fetch(url). The thing that finally unstuck me was a colleague drawing two stick figures on a whiteboard: one was a single chef cooking ten dishes, the other was the asyncio event loop running ten coroutines. The chef checks pots, sets timers, moves on while things simmer. The event loop does the same with I/O. Once I had that picture, the vocabulary mostly fell into place.
The argument I want to make is that asyncio is not exotic. It is a cooperative scheduler in your Python program. async def marks a function as one the scheduler can pause and resume. await is the pause point. The event loop runs whichever coroutine is currently ready. Most of the docs' jargon (Task, Future, awaitable, coroutine object) is plumbing you do not have to understand to use the library. Once you have one HTTP fetcher running concurrently, the rest of the API is easier to learn from the inside.
The mental model: one chef, many pots
A traditional threaded program is N cooks in a kitchen, each working on one dish, fighting for the stove. asyncio is one cook with N timers. The cook starts a dish, sets a timer for the simmer, walks to the next station, starts another dish, sets a timer, and so on. When a timer dings, the cook finishes that dish.
In asyncio, "setting a timer" is await. The cook is the event loop. The dishes are coroutines. The whole thing runs on a single thread, with no parallelism for CPU work, but with massive concurrency for I/O. While one coroutine is waiting on a slow API, the loop is happy to run nine others.
The kitchen analogy carries you about 80% of the way through asyncio. The remaining 20% is concurrency primitives (locks, queues, semaphores) that have direct counterparts in threading and behave the same in spirit.
The minimal working example
Before any vocabulary, here is the smallest non-trivial asyncio program: fetching three URLs concurrently with aiohttp. Compare it to the requests version side-by-side and the value proposition is obvious.
The shape is recognisable. async def instead of def. await in front of any operation that might pause. asyncio.gather(*coros) runs the coroutines concurrently and waits for all of them. asyncio.run is the entrypoint that starts the event loop, runs the top-level coroutine, and closes the loop.
Three URLs taking one second each: synchronous total is three seconds, asyncio total is one second. The win scales with how many concurrent waits you have, capped by the slowest one.
The vocabulary I actually use
Forget half of what the docs introduce. The terms I think about daily, in order of frequency:
async def fn()declares a coroutine function. Callingfn()returns a coroutine object; the body has not run yet. It runs when the coroutine is awaited or scheduled on the loop.await xsuspends the current coroutine untilxcompletes, then resumes withx's result.xhas to be an awaitable: a coroutine object, a Task, or a Future.asyncio.gather(*coros)runs multiple coroutines concurrently, returns when all complete, and gives you a list of results in order.asyncio.create_task(coro)schedules a coroutine to run in the background. Use this when you want fire-and-forget concurrency, or when you want to await the task later.asyncio.run(main())runs a coroutine to completion in a new event loop. The top-level entrypoint of an asyncio program.
The terms I rarely think about: Future, Task (as distinct from create_task), the event loop instance, loop.run_until_complete. These show up in advanced cases (custom executors, integration with other event loops, low-level scheduling). For 90% of asyncio code, the five primitives above are enough.
Trap 1: blocking calls inside async def
The single most common asyncio bug is calling a synchronous, blocking function from a coroutine. The function blocks the entire event loop, every other coroutine stalls, and the program runs serially despite all the async decorations.
The fix is to use an async-native library (aiohttp, httpx with AsyncClient) or, if you must call a sync function, run it in a thread:
asyncio.to_thread runs the function on a thread pool and gives you back an awaitable. The thread is parallel to the event loop; the loop is not blocked. This is the right escape hatch when no async-native version of a library exists. CPU-bound work is a different story and belongs in loop.run_in_executor with a ProcessPoolExecutor.
The rule: never call a function that blocks for more than a few microseconds from a coroutine without wrapping it. Common offenders: requests, time.sleep, open on a slow filesystem, subprocess.run, blocking ORM operations.
Trap 2: forgetting await
A coroutine without await is a coroutine object, not a result. If you write fetch(url) instead of await fetch(url), you get a coroutine object back, the body never runs (until garbage collection, which logs a warning), and the call site uses garbage.
The Python warning that catches this is RuntimeWarning: coroutine 'fetch' was never awaited. Treat that warning as a fatal error. If you see it in tests, fix the missing await immediately.
A related case is wanting fire-and-forget: "start this coroutine but do not wait". The shape is task = asyncio.create_task(fetch(url)), not just fetch(url). create_task schedules the coroutine on the loop; you can await task later, or ignore it if you genuinely do not need the result. Plain calls are bugs.
Trap 3: mixing sync libraries deep in the call stack
A codebase converting to asyncio often has a layered problem: the HTTP handler is async, the service layer is async, but the database layer is the synchronous SQLAlchemy 1.x. The handler awaits the service, the service calls the database, and the database call blocks the event loop. Throughput collapses.
The paths out are: switch to an async-native library (asyncpg, SQLAlchemy 2.x async, motor for MongoDB), wrap each blocking call with asyncio.to_thread (correct but adds thread-pool latency), or stay synchronous and use threads instead of asyncio. The third option is underrated for medium-throughput services; threads scale fine for I/O up to a few hundred connections, and the code stays readable.
My rule of thumb: asyncio earns its keep when you have hundreds or thousands of concurrent I/O operations and the libraries you depend on all support it. For tens of concurrent operations on a service with mixed sync/async libraries, threads are simpler and cheaper.
Concurrency primitives, briefly
Three more pieces of the standard library worth knowing once you have gather and create_task.
asyncio.Queueis the producer/consumer queue between coroutines. Same shape asqueue.Queue, but withawait put()andawait get(). The single most useful pattern I reach for when the work is irregular: one coroutine pulls items off some source (a paginated API, a directory walk, a stream of events) and N worker coroutines pull from the queue and process. Backpressure is automatic; if the workers fall behind,put()blocks once the queue hits itsmaxsize.
The maxsize=20 is the backpressure knob. The None sentinels are how I shut workers down cleanly without an extra coordination channel. I use this exact shape for batch crawlers, log shippers, and any "fan out, process, fan in" pipeline where the work rate is bursty.
asyncio.Semaphore(n)caps concurrent operations. The shapeasync with sem: ...is the asyncio way to say "at most n of these at once". Useful for limiting outbound API calls.asyncio.timeout(seconds)wraps a block in a timeout. Use this in awithblock; the inner code is cancelled withTimeoutErrorif it runs over.
This is the pattern I use whenever a third-party API has a rate limit. Cap the concurrency in one line, fan out the requests, the semaphore handles the queuing.
When asyncio is the right tool, and when it is not
asyncio is a useful tool when you have a problem it fits. The problem it fits is "I have many concurrent I/O operations and I want to run them on one thread without the threading model overhead". If your problem is anything else (CPU-bound, naturally serial, two-or-three concurrent calls a second), do not use it. Stay synchronous, use a thread pool, use multiprocessing for CPU work, and skip the colour-coded function world that asyncio creates (where every async function can only call other async functions, and sync code lives behind a to_thread wall).
When the problem does fit, asyncio is excellent. The throughput numbers for I/O-heavy services are real, the code is readable once you internalise the chef-and-pots model, and the standard library has the primitives you need. Most of the pain people hit is from forcing asyncio into a codebase that did not need it, or from sprinkling async and await over a problem that was actually CPU-bound. Pick the tool that matches the problem; if asyncio is right, it is genuinely a joy. If it is wrong, the pain is structural and no amount of await will fix it.
