Python gather vs wait: Choosing the Right asyncio API
python gather vs wait: Compare asyncio.gather and asyncio.wait: ordered results, error propagation, cancellation, completion policies, and when to use each.
When you need to run several coroutines concurrently, asyncio.gather() and asyncio.wait() are the two APIs developers reach for first. The python gather vs wait decision comes down to how each function reports results, how it treats exceptions, and how it behaves under cancellation. They are not interchangeable: gather collects ordered results and propagates the first failure, while wait returns the sets of completed and pending tasks and leaves error inspection to you.
What gather Returns
asyncio.gather() accepts any number of awaitables as positional arguments and returns a single future whose result is a list. The list preserves the order of the arguments you passed, not the order in which the coroutines finished.
import asyncio async def fetch(name: str, delay: float) -> str: await asyncio.sleep(delay) return f"{name}:{delay}" async def main(): results = await asyncio.gather( fetch("a", 0.3), fetch("b", 0.1), fetch("c", 0.2), ) print(results) # ['a:0.3', 'b:0.1', 'c:0.2'] asyncio.run(main())
Even though fetch("b", 0.1) finishes first, its result appears in position 1 because that is where it was passed. This ordering makes gather the natural choice when you need to zip results back to a known sequence of inputs, such as batch HTTP requests where each response must line up with its request.
What wait Returns
asyncio.wait() takes an iterable of awaitables and returns a tuple of two sets: done and pending. It does not give you a result list. To read the outcome of each coroutine you must iterate the done set and call result() on each task.
import asyncio async def main(): tasks = [ asyncio.create_task(fetch("a", 0.3)), asyncio.create_task(fetch("b", 0.1)), asyncio.create_task(fetch("c", 0.2)), ] done, pending = await asyncio.wait(tasks) for task in done: print(task.result()) asyncio.run(main())
The done set is unordered, so you cannot assume any relationship between iteration order and the original input list. If you need per-input results, you either map tasks back to their inputs or you use gather. The pending set is empty when wait returns under the default ALL_COMPLETED policy, but it becomes meaningful when you pass a timeout or a different return_when policy.
Error Handling: Propagate or Inspect
The most consequential difference between the two APIs is how exceptions surface.
With gather, if any awaitable raises, the exception propagates out of the awaited gather future, and the remaining children are cancelled. This fail-fast behavior is what you want when a partial failure makes the whole batch meaningless. To keep failures as results instead, pass return_exceptions=True:
async def fail() -> None: raise ValueError("boom") async def main(): results = await asyncio.gather(fail(), return_exceptions=True) print(results) # [ValueError('boom')]
With wait, exceptions are never raised by the await asyncio.wait(...) call itself. They are stored on the individual tasks, and you must call task.result() or task.exception() to observe them:
async def main(): task = asyncio.create_task(fail()) done, pending = await asyncio.wait({task}) print(task.exception()) # ValueError('boom')
Calling task.result() on a failed task re-raises the exception, so you can wrap it in a try/except. This inspect-then-decide flow is useful when one failing coroutine should not abort the others, or when you want to collect every failure before taking action.
Cancellation Semantics
Cancelling the future returned by gather cancels all of the child tasks. There is no way to cancel only a subset through the gather future itself; you would have to cancel individual tasks you created and passed in.
With wait, cancellation behaves differently. If you cancel the wait coroutine, the tasks it was waiting on are not cancelled automatically. They remain running, and you get them back in the pending set so you can decide what to do with them. This is particularly relevant when you use a timeout:
async def main(): tasks = [ asyncio.create_task(fetch("a", 5.0)), asyncio.create_task(fetch("b", 0.1)), ] done, pending = await asyncio.wait(tasks, timeout=1.0) for task in pending: task.cancel()
The pending set gives you an explicit handle on the work that did not finish, which is exactly the information you need to implement a graceful shutdown or a retry policy. gather has no built-in timeout parameter; you would wrap it in asyncio.wait_for() and accept that the entire batch is cancelled when the timeout fires.
Completion Policies and Timeouts
wait supports a return_when argument that controls when it returns:
| Policy | Returns when |
|---|---|
ALL_COMPLETED | every awaitable has finished |
FIRST_COMPLETED | at least one awaitable has finished |
FIRST_EXCEPTION | at least one awaitable has raised |
FIRST_COMPLETED is the basis for patterns like racing several providers and using whichever responds first:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
gather has no equivalent. It always waits for everything to finish, or for the first exception to cancel the batch. If you need "return as soon as one succeeds," wait (or asyncio.as_completed()) is the tool.
Choosing Between gather and wait
The decision criteria are concrete:
- Use
gatherwhen you need all results in input order, want the first exception to fail the batch, or want to collect exceptions as values withreturn_exceptions=True. - Use
waitwhen you need to react to the first completion, need a timeout that leaves unfinished tasks inspectable, or must distinguish completed from pending work. - Use
asyncio.as_completed()when you want to process results as they finish rather than in input order.
If you find yourself calling task.result() on every element of a done set and then sorting by input order, gather is the simpler expression of the same intent.
Deprecation and Modern Alternatives
Since Python 3.11, asyncio.wait() is deprecated. The asyncio documentation steers new code toward asyncio.TaskGroup for structured concurrency and asyncio.timeout() for timeouts. A TaskGroup gives you gather-like result collection with stricter cancellation semantics: when one task fails, the group cancels the others and the exception propagates, which matches the fail-fast behavior of gather but with cleaner cleanup guarantees.
async def main(): async with asyncio.TaskGroup() as tg: t1 = tg.create_task(fetch("a", 0.3)) t2 = tg.create_task(fetch("b", 0.1)) print(t1.result(), t2.result())
For the "first successful result wins" pattern, asyncio.as_completed() or a manual loop over wait(..., return_when=asyncio.FIRST_COMPLETED) remains appropriate. When you maintain code on an older Python version, the behavior described above for gather and wait still applies, but verify the deprecation status for the exact interpreter you target before adopting either API in new code.