Async/Await in Python
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This is the field guide to what goes wrong, and it is the most practically valuable post in the set. Async failures are insidious because they usually are not crashes — they are code that runs correctly and passes tests while delivering none of the concurrency you wrote it for. Your throughput silently equals the blocking version's.
The six mistakes here cover the vast majority of real async bugs: forgetting await, blocking the loop, awaiting in a loop, mixing in sync libraries, losing track of tasks, and swallowing exceptions. Learn to recognize each by its symptom and most async debugging becomes pattern matching.
Mistake one is the forgotten await, the most common async bug by far. Calling an async function builds a coroutine object and, if you do not await it, immediately discards it — the body never runs. Python may warn with 'coroutine was never awaited,' but in larger codebases that warning is easy to miss, and the visible effect is simply that expected work did not happen.
The rule is absolute: anything defined with async def must be awaited or scheduled as a task to run. When a piece of async code mysteriously has no effect — a save that did not save, a log that did not log — a missing await is the first suspect every single time.
This snippet shows the bug and both fixes side by side. 'save(1)' alone constructs a coroutine and throws it away; the database write never happens. 'await save(1)' actually drives the coroutine to completion. asyncio.create_task(save(2)) schedules it to run concurrently, which you would use when you do not want to wait for it inline.
The takeaway is that calling is not running in async land. Pick await when you need the result or the ordering, and create_task when you want it running alongside other work. Either way, the bare call with neither is almost always a mistake.
Mistake two is the most damaging: blocking the event loop. Because the loop switches only at await, any synchronous operation that does not yield holds the single thread hostage. time.sleep, a synchronous requests.get, a pandas computation, or a tight CPU loop will all freeze the loop, and every other task stalls until that call returns.
The symptom is uniquely confusing — your whole server appears to hang, latency spikes across completely unrelated requests, and the blocked task itself looks innocent. The fix is to use async equivalents that yield (asyncio.sleep, an async HTTP client) or, for genuinely blocking work you cannot avoid, offload it to a thread or process pool so the loop stays free.
This shows the fix in code. asyncio.sleep replaces time.sleep because it yields to the loop instead of freezing it. The commented requests.get is the kind of sync call to avoid inside a coroutine. And for unavoidable blocking work — a legacy sync library, a CPU-heavy function — loop.run_in_executor pushes it onto a thread pool and returns an awaitable, so your coroutine awaits the result while the loop keeps serving everyone else.
run_in_executor is the essential escape hatch. It lets you integrate blocking code into an async program without sacrificing the loop, by isolating the blocking on a separate thread. Reach for it whenever you must call something synchronous that you cannot replace with an async version.
Mistake three is the quiet performance killer: awaiting inside a loop. Writing a for-loop that awaits each operation before starting the next produces code that is structurally async but behaves exactly like blocking code — each await fully completes before the next begins, so there is zero overlap.
This is the number one reason developers report that their async code 'is not any faster.' It looks concurrent because it uses await, but it serializes everything. The fix is to separate launching from awaiting: build all the coroutines first, then await them together with gather so their waits overlap. The distinction between sequential awaits and a single gather is the difference between using async and merely writing it.
This snippet puts the two patterns side by side. The for-loop appends 'await fetch(u)' one at a time, so the total time is the sum of every fetch — sequential, despite the async syntax. The gather version builds a generator of fetch coroutines and awaits them all at once, so they run concurrently and the total time is the slowest single fetch.
For ten one-second requests, that is ten seconds versus roughly one. The lesson generalizes: whenever you have a collection of independent async operations, do not await them in a loop — collect them and gather. Awaiting in a loop is correct only when each step genuinely depends on the previous one's result.
This bar chart quantifies mistake three so it sticks. For ten independent one-second calls, awaiting in a loop takes the full ten seconds because each runs after the last. gather runs them concurrently, so the total is about one second — the duration of a single call, since they all overlap.
The roughly tenfold gap is not an edge case; it is the normal magnitude of this mistake. Any time independent I/O is serialized by an await-in-loop, you forfeit a speed-up proportional to how many operations you could have overlapped. This single chart is the strongest argument for internalizing the gather pattern.
Mistake four is mixing synchronous libraries into async code, which silently reintroduces the blocking problem from a different direction. A synchronous database driver, the plain requests library, or any blocking SDK call inside a coroutine freezes the loop exactly as time.sleep would — and it is easy to do by accident because the code looks fine.
The discipline is to match async with async across the stack: aiohttp or httpx for HTTP, asyncpg for Postgres, aiofiles for files, async clients for queues and caches. When no async version of a dependency exists, do not call it directly in a coroutine — wrap it in loop.run_in_executor so the blocking happens on a thread and the loop stays responsive.
Mistake five is losing your tasks. asyncio.create_task returns a Task object, and if you do not keep a reference to it, the loop holds only a weak reference — the garbage collector can reclaim the task mid-flight, and its work simply vanishes. Worse, exceptions raised inside an unreferenced fire-and-forget task can go completely unreported.
The fix is to retain references: store tasks in a list and await them before the program exits, or use a TaskGroup. This is one of the more surprising async footguns because the code looks like it launched work, and sometimes it even runs to completion in testing, only to be collected unpredictably under load.
This shows the modern, robust answer: asyncio.TaskGroup, available in Python 3.11 and later. Inside 'async with asyncio.TaskGroup() as tg', every tg.create_task is owned by the group. When the block exits, the group awaits all of them, so nothing is lost to garbage collection. If any task raises, the group cancels the others and propagates the exception.
TaskGroup is structured concurrency: the lifetime of the tasks is bounded by the with block, errors are not swallowed, and you cannot accidentally leave tasks dangling. On 3.11+ it is the preferred way to launch a group of concurrent tasks, replacing the more error-prone manual gather-with-create_task patterns.
This checklist is the whole post compressed into a pre-flight scan. Await or schedule every coroutine so nothing silently does nothing. Keep all blocking calls out of the loop, using run_in_executor when you must. Use gather to overlap independent work instead of awaiting in a loop. Choose async-native libraries so you do not reintroduce blocking. Keep references to your tasks so they are not garbage-collected. And handle exceptions from gather and TaskGroup so failures surface instead of vanishing.
Run through these six before shipping any async code. Each maps directly to one of the mistakes above, and together they are the difference between async that genuinely scales and async that merely compiles.
That closes the mistakes post and the whole arc on async/await: you have the concept, the case for it, the mechanics, a working example, and now the failure modes that quietly undermine all of it.
The next day moves on to a fresh topic, but you now understand async end to end — what a coroutine and the event loop are, why I/O-bound systems live or die on this, how await suspends and resumes under the hood, how to fan out work concurrently, and the six traps that turn fast async back into slow blocking code.