Async/Await in Python
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This is the hands-on post, and it is deliberately code-heavy because async clicks when you watch it work. The running example is the canonical one: fetch many URLs concurrently instead of one at a time. It is the cleanest demonstration of async's core win and a pattern you will reuse constantly — scraping, calling APIs, fanning out requests.
We build it incrementally: one fetch, then many concurrent fetches, then the production hardening — bounded concurrency, timeouts, error handling — and finish with a timing proof. Type each step out; the muscle memory is the point.
Step one is a single async fetch built on aiohttp, the standard async HTTP client. The function takes a shared session and a URL. 'async with session.get(url) as resp' opens the request as an async context manager, which guarantees the connection is released even on error. raise_for_status turns a 4xx or 5xx into an exception, and 'await resp.text()' suspends while the body downloads.
The key detail is that the session is passed in, not created here. A ClientSession owns a connection pool, and reusing one across many requests lets connections be kept alive and reused. Creating a session per request — a common beginner mistake — throws that pooling away and is markedly slower.
Step two is where concurrency appears. fetch_all opens one session, builds a list of fetch coroutines — one per URL — and hands them to asyncio.gather with the star-unpack so each is a separate argument. gather schedules every coroutine as a concurrent task and waits for all of them, returning results in the original order.
Run this against twenty URLs and the requests overlap rather than queue. The wall-clock time is roughly that of the single slowest request, not the sum of all twenty. This eight-line function is the heart of the whole post: it is the difference between a scraper that takes a minute and one that takes a second.
This slide explains what gather actually does for you, because it is doing several things at once. It converts your coroutines into scheduled tasks so they run concurrently. It waits until every one has finished. And it returns their results as a list in the same order you passed them, regardless of which finished first — so you do not have to correlate results back to inputs yourself.
The shared session quietly does its part too: connection pooling means the twenty requests reuse a small set of underlying TCP connections rather than each paying full setup cost. Together, gather plus a shared session is the idiomatic, efficient way to fan out a batch of independent I/O.
Step three adds the single most important piece of production hygiene: bounded concurrency via asyncio.Semaphore. A semaphore is a counter of permits; 'async with sem' acquires one before the request and releases it after. With the limit set to ten, at most ten fetches are in flight at once, and the eleventh waits at the gate until one finishes.
Notice the structure stays the same — you still build all the tasks and gather them. The semaphore does not reduce how many tasks you create, only how many run their network call simultaneously. This is the standard way to get the overlap benefit while keeping a ceiling on resource use and politeness toward the target.
This explains why the semaphore matters, because beginners often skip it and pay for it. Firing ten thousand requests at once is not faster — it is a way to exhaust your process's file descriptors, saturate your own network stack, and hammer the target server hard enough to get rate-limited, throttled, or IP-banned.
A Semaphore gives you the sweet spot: enough concurrency to overlap waits and get the speed-up, capped low enough to stay stable and courteous. Ten to a few hundred is a typical range depending on the target. The rule of thumb is that unbounded concurrency is a bug, not a feature; always put a ceiling on a fan-out.
Step four adds the two things real fetches need: timeouts and per-task error handling. aiohttp.ClientTimeout(total=5) caps the whole request so a hung server cannot park a task forever — without it, one dead endpoint can tie up a slot indefinitely. Always set a timeout on network I/O.
The try/except wraps each fetch so one failure does not poison the batch; here it returns an error string instead of raising. The comment flags asyncio.gather's return_exceptions parameter: leave it False and the first exception propagates and cancels the gather; set it True and exceptions come back as result values you can inspect. Choosing between catching per-task and using return_exceptions is how you control failure semantics for the whole fan-out.
This comparison contrasts the two ways to collect results from many tasks. gather waits for all of them and returns one list in input order — simple, and the right default when you need every result before proceeding. as_completed is an iterator that yields each task as it finishes, in completion order.
Reach for as_completed when you want to process results as they arrive rather than waiting for the slowest — updating a progress bar, streaming partial output, or short-circuiting once you have enough. The mental split: gather is batch-and-wait, as_completed is stream-as-ready. Both run the tasks concurrently; they differ only in how you harvest the results.
This is the payoff: a self-contained timing proof you can run without a network. Fifty coroutines each sleep one second to simulate slow I/O, all launched together with gather. The measured wall-clock time prints at about one second, not fifty.
That collapse — fifty seconds of total work compressed into one second of real time — is the entire value proposition of async made concrete. The fifty sleeps overlap because each yields control at its await, so the loop keeps all fifty waiting simultaneously. Swap asyncio.sleep for real HTTP calls and the result is the same shape, bounded only by your concurrency limit and the slowest response.
This checklist distills the production lessons from the build. Reuse a single ClientSession so connections are pooled rather than recreated per request. Always set a ClientTimeout so a single hung endpoint cannot stall a slot forever. Cap concurrency with a Semaphore so a fan-out stays stable and polite.
Handle per-task errors explicitly — decide whether one failure should abort the batch or be collected — so failures are visible rather than silent. And never call blocking I/O inside a coroutine, because it freezes the whole loop and erases every benefit you just built. These five rules separate a toy script from code you can trust in production.
The classic beginner trap deserves its own slide: calling an async function without awaiting it. fetch_all(urls) on its own merely constructs a coroutine object — a plan of work — and then discards it. No requests fire. If you are lucky, Python emits a 'coroutine was never awaited' RuntimeWarning; if not, the program just silently does nothing where you expected work.
The underlying reason is that async functions are lazy by design: calling one builds the coroutine, and only awaiting it (or scheduling it as a task, or running it via asyncio.run) actually drives it. Whenever you call something defined with async def and 'nothing happens,' a missing await is the first thing to check.
That completes the practical build: one fetch, many concurrent fetches with gather, bounded concurrency with a semaphore, timeouts and error handling, and a timing proof — plus the forgotten-await trap that bites everyone once.
Day 23 in this slot turns the lens to failure modes: the common async mistakes that do not crash but quietly destroy your concurrency, so your 'async' code runs exactly as slowly as the blocking version it was supposed to replace.