Ch. 9 · Python

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readadvancedupdated Oct 19, 2026

asyncio.gather is usually the first concurrency tool Python developers learn: pass it several coroutines, await it, get a list of results in the same order. It’s simple until something goes wrong. One request fails, or one is slow, and suddenly you need to know things the happy path never asked: do the other tasks keep running? Are their results lost? What does a timeout actually cancel?

The answers aren’t obvious from the call, and they’ve changed as asyncio gained TaskGroup and asyncio.timeout in Python 3.11. This guide answers each question with a runnable experiment, then gives you a way to choose between the tools. All output comes from Python 3.14 using a fake fetch coroutine that sleeps, optionally raises, and prints when it finishes or gets cancelled.

The coroutine used in every example

Every example uses this coroutine. The except CancelledError block only prints and re-raises, so you can see exactly when cancellation reaches each task:

import asyncio, time

T0 = time.perf_counter()
def t():
    return f"{time.perf_counter() - T0:.1f}s"

async def fetch(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ConnectionError(f"{name} refused")
        print(f"  {name} done at {t()}")
        return name
    except asyncio.CancelledError:
        print(f"  {name} cancelled at {t()}")
        raise
python

Re-raising CancelledError matters. If you catch it and carry on, the task doesn’t actually stop, and code waiting for it to stop can hang.

When one awaitable fails

Here’s a dashboard loading three data sources, and the users service refuses the connection after 0.2 seconds:

try:
    await asyncio.gather(
        fetch("users", 0.2, fail=True),
        fetch("orders", 1.0),
        fetch("prices", 0.5),
    )
except ConnectionError as e:
    print(f"  caught {e!r} at {t()}")
await asyncio.sleep(1.0)
python
  caught ConnectionError('users refused') at 0.2s
  prices done at 0.5s
  orders done at 1.0s
Text

With the default return_exceptions=False, gather propagates the first exception to whoever awaited it as soon as it happens, at 0.2 seconds. But the other two awaitables are not cancelled. They keep running in the background and finish at 0.5 and 1.0 seconds, and their results go nowhere.

That’s the behaviour that catches people out. In a web handler, you’ve returned an error page to the user while two upstream calls are still consuming connections. If those calls write to a database, the writes still happen after you’ve reported failure. And if one of them later fails too, nobody retrieves that exception, so asyncio logs “Task exception was never retrieved” when the task is garbage-collected.

The Python docs state it directly: if return_exceptions is false, other awaitables in the sequence won’t be cancelled and will continue to run. Knowing that sentence, and its consequences, is most of what an interviewer wants when they ask about gather errors.

return_exceptions=True: collect everything

Passing return_exceptions=True changes the contract. Exceptions are treated like results and placed in the output list at their position:

results = await asyncio.gather(
    fetch("users", 0.2, fail=True),
    fetch("orders", 0.4),
    return_exceptions=True,
)
print(results)
python
  orders done at 0.4s
[ConnectionError('users refused'), 'orders']
Text

gather waits for everything to finish, cancels nothing, and never raises because of a child’s failure. You check each item with isinstance(r, Exception). This suits partial-success work, such as a dashboard where a broken price widget shouldn’t blank the whole page, or a batch job where you report which of 500 items failed.

The cost is that failures become values you have to check. Forget to check, and you’ll pass a ConnectionError object into a template as if it were data. One more subtlety: a child that gets cancelled appears in the list as a CancelledError instance, which is a BaseException, not an Exception, so an isinstance(r, Exception) check misses it.

Putting a deadline on the whole gather

The usual next requirement is “give up after half a second”. Wrapping the gather in asyncio.wait_for works, with a trade-off:

try:
    await asyncio.wait_for(
        asyncio.gather(fetch("users", 0.2), fetch("orders", 1.0), fetch("prices", 2.0)),
        timeout=0.5,
    )
except TimeoutError:
    print(f"  TimeoutError at {t()}")
python
  users done at 0.2s
  orders cancelled at 0.5s
  prices cancelled at 0.5s
  TimeoutError at 0.5s
Text

When the deadline passes, wait_for cancels the gather, and cancelling a gather cancels every child that’s still running. That’s good: nothing is left behind. But users finished successfully at 0.2 seconds, and its result is gone. The timeout is all-or-nothing.

Since Python 3.11, asyncio.TimeoutError is an alias of the built-in TimeoutError, so except TimeoutError catches it. On older versions you need asyncio.TimeoutError specifically, which is a common source of timeouts that seem to escape their handler.

Keeping the results that finished

If you want whatever completed within the deadline, asyncio.wait is the better tool. It returns two sets instead of raising:

tasks = [asyncio.create_task(fetch(n, d)) for n, d in [("users", 0.2), ("orders", 1.0)]]
done, pending = await asyncio.wait(tasks, timeout=0.5)
for p in pending:
    p.cancel()
await asyncio.gather(*pending, return_exceptions=True)
print("done:", [d.result() for d in done], "pending cancelled:", len(pending))
python
  users done at 0.2s
  orders cancelled at 0.5s
done: ['users'] pending cancelled: 1
Text

Notice the three manual steps. wait takes tasks, not bare coroutines, so you create them yourself. It does not cancel anything on timeout, so you cancel the pending tasks. Then you await them so their cancellation actually completes before you move on. Leave out that last step and the tasks may still be unwinding (closing connections, running finally blocks) after your function returns. Also, done is a set, so results come back unordered; map tasks back to their inputs if order matters.

Per-call deadlines with partial results

Often what you really want is a deadline on each call, with failures reported individually. Combining asyncio.timeout with return_exceptions=True does that in a few lines:

async def guarded(name, delay):
    async with asyncio.timeout(0.5):
        return await fetch(name, delay)

results = await asyncio.gather(
    guarded("users", 0.2), guarded("orders", 1.0), guarded("prices", 0.3),
    return_exceptions=True,
)
python
  users done at 0.2s
  prices done at 0.3s
  orders cancelled at 0.5s
['users', 'TimeoutError', 'prices'] at 0.5s
Text

(The last line prints the type name for exceptions to keep it short.) Each call gets its own deadline, results keep their order, and the slow one shows up as a TimeoutError in its slot. For a dashboard, this is usually the shape you want.

TaskGroup: structured concurrency

Python 3.11 added asyncio.TaskGroup, which gives you the opposite default from gather: if any task fails, the rest are cancelled, and the block doesn’t exit until every task has finished.

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch("users", 0.2, fail=True))
        tg.create_task(fetch("orders", 1.0))
except* ConnectionError as eg:
    print(f"  caught {eg.exceptions!r} at {t()}")
python
  orders cancelled at 0.2s
  caught (ConnectionError('users refused'),) at 0.2s
Text

orders was cancelled the moment users failed, so nothing is left running when control reaches the handler. Errors arrive as an ExceptionGroup, because several tasks can fail at once, which is why the example uses except*. That’s the main practical adjustment if you’re moving from gather.

TaskGroup composes cleanly with asyncio.timeout for an all-or-nothing deadline:

async with asyncio.timeout(0.5):
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch("users", 0.2))
        tg.create_task(fetch("orders", 1.0))
python

At 0.5 seconds the timeout cancels the block, the group cancels orders, and a TimeoutError comes out of the timeout block. Unlike wait_for, asyncio.timeout can wrap any amount of code, not just one awaitable, and its deadline can be moved later with reschedule().

Choosing between them

A short decision guide:

  • All results are required, and one failure means the operation failed: TaskGroup, inside asyncio.timeout if there’s a deadline.
  • Partial success is acceptable and you want every outcome in order: gather(..., return_exceptions=True), with asyncio.timeout inside each coroutine for per-call deadlines.
  • You want whatever finishes by a deadline and will deal with stragglers yourself: asyncio.wait with a timeout, plus explicit cancel and await.
  • Plain gather without return_exceptions is fine when failure means the program is exiting anyway. In long-running services, its leave-the-others-running behaviour is rarely what you want.

None of these tools can interrupt blocking code. A synchronous requests.get or a CPU-heavy loop inside a coroutine blocks the event loop, and timeouts can’t fire until it yields. Move such work to asyncio.to_thread or a process pool.

Interview question: the flaky search aggregator

Question. A FastAPI endpoint searches three providers with await asyncio.gather(search_a(q), search_b(q), search_c(q)) and merges the results. Provider C sometimes takes 10 seconds or raises. Users see either very slow responses or a 500 error, even when A and B answered within 200 ms. Logs also show “Task exception was never retrieved”. Explain each symptom and redesign the call.

Answer. The slowness comes from gather waiting for every awaitable, so the response takes as long as the slowest provider. The 500s come from the default return_exceptions=False: when C raises, gather re-raises immediately and the handler fails, discarding A and B’s good results. Because gather doesn’t cancel the other awaitables on failure, any still in flight keep running after the response is sent, and if one of them later raises, nobody retrieves its exception, which produces the log message. The redesign gives each provider its own deadline and treats failure as partial data: wrap each search in async with asyncio.timeout(0.8), call gather(..., return_exceptions=True), merge the results that are lists, and log or count the exceptions (recording which provider timed out). Every task finishes or is cancelled before the handler returns, so nothing leaks. A strong answer also mentions returning a flag such as partial: true so the UI can say some results are missing, and checking that the provider clients use async HTTP, because a blocking client would defeat the timeouts completely.

Next steps

More in Python

read ✓Python · mid

Python Bounded Async Worker Queues

Build an asyncio worker pool with a bounded Queue: backpressure via maxsize, task_done and join, per-item error handling and clean shutdown.

~8 min readread →
read ✓Python · mid

Python Asyncio and Blocking Functions

Why time.sleep or a sync HTTP call inside a coroutine freezes every task, how to spot it with debug mode, and when to_thread is enough.

~6 min readread →
read ✓Python · mid

Python Asyncio Tasks and Cancellation

Who owns an asyncio task: why fire-and-forget loses errors, how TaskGroup cancels siblings, and why swallowing CancelledError hangs shutdown.

~6 min readread →
esc