Tasks & gather
Scenario
You need to call three microservices, each taking 1 second. You `await` them one after another and the endpoint takes 3 seconds. But the calls don't depend on each other — they could all be in flight at once. How do you get it down to 1 second?
Mental model
`await` on its own is like mailing three letters and waiting by the mailbox for each reply before sending the next. `create_task` / `gather` is dropping all three in the box at once, then waiting for the whole batch. The waiting overlaps, so the batch finishes about as fast as the slowest single letter.
A bare `await coro()` is sequential — it starts the coroutine and blocks until it's done. To overlap independent coroutines you must first *schedule* them so the event loop starts running them concurrently. `asyncio.create_task(coro())` wraps a coroutine in a Task and hands it to the loop immediately (it starts running at the next `await`). `asyncio.gather(*coros)` is the common shortcut: it schedules several awaitables as concurrent tasks and returns a single awaitable that resolves to a list of their results, in the original order, once all finish. Total time collapses from the *sum* of the waits to roughly the *maximum*.
Deep dive
The trap: `await a(); await b(); await c()` runs strictly in sequence — each `await` fully completes before the next starts, so three 1-second calls take 3 seconds. `await` means 'run this and wait here,' not 'run this in the background.' Sequential awaits are correct only when each step *depends* on the previous result.
Back-to-back `await`s are sequential; their durations add up.
`asyncio.gather(a(), b(), c())` schedules all three as concurrent tasks and awaits the batch. They run overlapped on the one loop, so the total is ~max(durations), not the sum. `gather` returns results as a list in the *same order you passed the coroutines*, regardless of which finished first — so you can safely unpack `x, y, z = await gather(...)`.
`gather` runs awaitables concurrently and returns their results in input order.
`asyncio.create_task(coro())` is the lower-level primitive: it schedules one coroutine right away and returns a Task handle you can `await` later, check with `.done()`, or `.cancel()`. Use it when you want a coroutine running in the background while you do other work, then await it at the point you actually need its result. `gather` is essentially create-task-then-await-all wrapped up for the common fan-out case.
`create_task` starts one coroutine now and gives you a handle to await/cancel later.
Error handling differs. By default, if any coroutine in `gather` raises, `gather` propagates that first exception immediately — but the other tasks keep running (they aren't auto-cancelled), which can leak work. Pass `return_exceptions=True` to instead collect exceptions as ordinary items in the results list, so one failure doesn't sink the batch. (In modern code, `asyncio.TaskGroup` gives structured cancellation — a failure cancels its siblings — but `gather` remains the ubiquitous interview answer.)
`gather` raises on the first error by default; `return_exceptions=True` collects errors instead.
Code examples
Sequential vs. concurrent
import asyncio, time
async def call_service(name):
await asyncio.sleep(1) # each call takes 1s
return f"{name}-ok"
async def sequential():
a = await call_service("A")
b = await call_service("B")
c = await call_service("C")
return [a, b, c] # ~3s total
async def concurrent():
# scheduled together -> overlap -> ~1s total
return await asyncio.gather(
call_service("A"),
call_service("B"),
call_service("C"),
)
async def main():
t = time.perf_counter()
print(await sequential(), f"{time.perf_counter()-t:.1f}s")
t = time.perf_counter()
print(await concurrent(), f"{time.perf_counter()-t:.1f}s")
asyncio.run(main())
# ['A-ok','B-ok','C-ok'] 3.0s
# ['A-ok','B-ok','C-ok'] 1.0s
`sequential` awaits each call before starting the next, so the 1-second waits add to 3s. `gather` schedules all three at once; their waits overlap on the single loop, so the batch finishes in ~1s. Note `gather` preserves input order (A, B, C) even though they run together.
create_task for background work
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay)
return name
async def main():
# start it now; it runs while we do other work
task = asyncio.create_task(fetch("report", 2))
await do_other_setup() # happens concurrently with fetch
result = await task # await only when we need it
print(result) # 'report'
async def do_other_setup():
await asyncio.sleep(1)
asyncio.run(main())
`create_task` kicks off `fetch` immediately and returns a handle. The setup work runs during fetch's 2-second wait, so we only pay for the overlap. We `await task` at the point we actually need its result — and could have cancelled it with `task.cancel()` instead.
Not letting one failure sink the batch
import asyncio
async def ok(): return "ok"
async def boom(): raise ValueError("service down")
async def main():
# default: first exception propagates out of gather
try:
await asyncio.gather(ok(), boom())
except ValueError as e:
print("raised:", e) # raised: service down
# collect exceptions as results instead
results = await asyncio.gather(ok(), boom(), return_exceptions=True)
print(results) # ['ok', ValueError('service down')]
asyncio.run(main())
By default `gather` re-raises the first exception, so you handle it with try/except. With `return_exceptions=True`, each coroutine's outcome — value or exception object — lands in the results list by position, letting you inspect partial success instead of losing the whole batch.
Common mistakes
- Awaiting in a loop when calls are independent: `for url in urls: results.append(await fetch(url))` runs the fetches one at a time — the classic accidental-sequential bug. If the calls don't depend on each other, schedule them together: `results = await asyncio.gather(*(fetch(u) for u in urls))`.
- Expecting results in completion order: `gather` returns results in the order the coroutines were *passed in*, not the order they finished. If you need results as they complete, use `asyncio.as_completed(...)` instead — but for a fixed fan-out, rely on gather's positional ordering.
- A fire-and-forget task that's never awaited (or referenced): `asyncio.create_task(...)` without keeping the returned handle risks the task being garbage-collected mid-flight, and its exceptions vanish silently. Keep a reference and `await` it (or gather it) so errors surface and the work actually completes.
Recall questions
- Why does `await a(); await b()` take the sum of their durations instead of the max?
- What does `asyncio.gather(*coros)` do, and in what order are its results returned?
- How does `asyncio.create_task` differ from a plain `await`?
- By default, what happens to a `gather` if one of its coroutines raises?
Questions & answers
An endpoint fetches a user, their orders, and their recommendations from three independent services, each ~200ms, using three sequential `await`s — total ~600ms. How do you cut the latency, and what's the new expected time?
The three calls are independent, so run them concurrently with `user, orders, recs = await asyncio.gather(get_user(), get_orders(), get_recs())`. Scheduled together, their 200ms waits overlap on the event loop, so total latency drops to ~200ms (the slowest single call) instead of the ~600ms sum. `gather` also returns results in the order passed, so the unpacking is safe.
Approach: Recognize the calls are independent, replace sequential awaits with `gather` for concurrency, and state the new time is ~max(durations), not the sum.
You fan out 50 API calls with `asyncio.gather`. One call fails. Describe the default behavior and how you'd make the batch resilient to individual failures.
By default `gather` raises the first exception up to the caller, and the remaining tasks keep running in the background (not auto-cancelled), which can leak work. To be resilient, pass `return_exceptions=True` so each result — a value or an exception object — is returned by position; you then filter successes from failures and retry or log the failed ones. For structured cancellation where a failure should cancel siblings, `asyncio.TaskGroup` is the modern alternative.
Approach: State that gather raises the first error by default (and doesn't cancel siblings), then use `return_exceptions=True` to collect per-call outcomes; mention TaskGroup for structured cancellation.
Continue learning
Previous: Coroutines, async / await
Previous: The event loop