Tasks & gather

RoadmapsPython Backend

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

Recall questions

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

Next: Blocking Calls in Async Code

Return to Python Backend Roadmap