skip to content

When would you use asyncio.wait with FIRST_COMPLETED instead of asyncio.gather, and what must you clean up afterwards?

level: seniorimportance: nice to knowfreq 28%

answer

  1. Not every wait is a wait for everything
  2. Two collections come back, not results
  3. Nothing is raised on your behalf
  4. The leftovers are still running

basics

~20 s

Use asyncio.wait with return_when=asyncio.FIRST_COMPLETED when you only need the earliest answer, such as racing a cache against an authoritative source. It returns done and pending sets, raises nothing itself, and leaves the pending tasks running for you to cancel.

solid answer

~40 s

`asyncio.gather` is for "I need all of these, aligned with my inputs". `asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)` is for "wake me when the first one lands" — racing a cached lookup against an authoritative one, or waiting on the first of several shutdown signals. It returns a **tuple of two sets**, `done` and `pending`, of the very objects you passed; order is lost, and it **never re-raises a child's exception** — you must call `.result()` or `.exception()` on the members of `done` yourself. Anything still in `pending` is still running: the losers of the race keep burning work unless you cancel them and await the cancellations. Since 3.12 you must pass Tasks or Futures, not bare coroutines. When you want results in completion order rather than just the first, `asyncio.as_completed` is the better fit.

code

python · 26 lines
python
import asyncio


async def cached_count():
    await asyncio.sleep(0.01)
    return "cache: 41 units (83% hit rate, may be stale)"


async def authoritative_count():
    await asyncio.sleep(0.20)
    return "depot-7: 38 units"


async def main():
    tasks = [
        asyncio.create_task(cached_count()),
        asyncio.create_task(authoritative_count()),
    ]
    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
    print(done.pop().result())
    for task in pending:
        task.cancel()
    await asyncio.gather(*pending, return_exceptions=True)


asyncio.run(main())

go deeper

for a junior

Know that asyncio.wait exists and gives you two sets of tasks rather than a list of values, and that gather is the ordinary choice when you want all the results.

for a middle

Explain the return_when modes and the fact that wait never raises a child's exception. Be able to pull the outcome off a task in the done set and say why coroutines must be wrapped first.

for a senior

Show the full race pattern from a real system: take the winner, cancel every pending loser, await them with exceptions collected, and handle the case where the first finisher failed rather than succeeded.

for a principal

Own the pattern choice across a service: when racing a fast-but-stale source against an authoritative one is acceptable at all, what the abandoned work costs, and how cleanup obligations are made impossible to forget in shared helpers.

### The shape of the problem Imagine an inventory sync that can answer a stock question two ways: a cache that answers in milliseconds with an 83% hit rate but may hold a stale value, and an authoritative query to the depot system that takes far longer. For a read-mostly screen you want whichever answers first. `gather` is the wrong tool — it waits for both and gives you two answers you did not ask for. This is what `asyncio.wait` with `FIRST_COMPLETED` is for. ### What asyncio.wait actually returns ```python done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) ``` Four properties define its behaviour, and each is a place candidates go wrong: 1. **It returns sets of futures, not results.** `done` and `pending` contain the same Task objects you passed in. There is no ordering — a set is unordered even when one task obviously finished first — and there are no values until you ask each task for its outcome. 2. **It does not raise on your behalf.** If the first task to finish did so by raising, `wait` still returns normally and that task simply sits in `done` carrying an exception. `task.result()` re-raises it; `task.exception()` hands it over as a value. Forgetting this turns a failure into a silent one, and can also produce "exception was never retrieved" noise later. 3. **The `timeout` argument does not cancel anything and does not raise.** When it expires, `wait` returns with the unfinished tasks in `pending`, still running. That is deliberate — `wait` reports, it does not manage. Timeouts that must actually *stop* work are a different mechanism. 4. **The pending set is your responsibility.** After you take the winner, cancel the losers and await them so the cancellation actually lands and their `finally` blocks run: ```python for task in pending: task.cancel() await asyncio.gather(*pending, return_exceptions=True) ``` The `return_exceptions=True` there is load-bearing: awaiting cancelled tasks otherwise raises `CancelledError` straight back at you. ### The other return_when modes `asyncio.ALL_COMPLETED` is the default and makes `wait` a lower-level, order-losing `gather`. `asyncio.FIRST_EXCEPTION` returns as soon as any task finishes by raising — or, if none does, when all have finished. `FIRST_EXCEPTION` is occasionally the right primitive for supervising a set of long-lived tasks where the first failure should trigger a coordinated shutdown, precisely because it hands you the still-pending set to cancel. ### Coroutines are not accepted Since 3.12, passing bare coroutine objects to `asyncio.wait` raises `TypeError`. The reason is directly tied to property 1: because `wait` returns the objects you gave it, callers who passed coroutines got back Tasks they had never seen and could not correlate with anything. Wrap with `asyncio.create_task` first — which is what you want anyway, since you need the handles to cancel the pending set. Note the contrast with `gather`, which still accepts coroutines and wraps them silently. ### as_completed, when you want them in order of arrival If the requirement is not "the first one" but "each one as soon as it is ready", `asyncio.as_completed` is the tool. Since 3.13 the object it returns can be used with `async for`, and asynchronous iteration yields **the originally supplied tasks**, which is what makes correlation possible: ```python async for finished in asyncio.as_completed(tasks): name = await finished # or finished.result() ``` Before 3.13 the only usage was plain iteration yielding fresh coroutines that you awaited to get the next result — usable, but it gave you no way to tell *which* input had just completed, which is why the async-iteration form was added. Note that `as_completed` also does not cancel anything if you abandon the iteration early; the same cleanup obligation applies. ### Choosing between the three * **gather** — you need every result, correlated positionally with the inputs. The default fan-out. * **as_completed** — you need every result, but want to start processing each the moment it lands rather than after the slowest. * **wait with FIRST_COMPLETED** — you need one answer and the rest are disposable. Always followed by an explicit cancel-and-drain of `pending`. And the meta-point an interviewer is listening for: `asyncio.wait` is a *reporting* primitive, not a *managing* one. It tells you what has happened and hands the still-running work back to you. Every one of its surprises — no exceptions raised, no cancellation on timeout, sets rather than order — follows from that one design decision.

  • What does asyncio.wait's timeout argument do to the tasks that have not finished?
    Nothing at all. `wait` returns them in the `pending` set, still running on the loop, and raises no timeout error — the docstring says so explicitly. It is a reporting deadline, not a cancellation. If unfinished work must actually stop, you cancel those tasks yourself, or use one of asyncio's dedicated timeout constructs, which are built precisely to enforce the deadline rather than merely observe it.
  • Why is passing a bare coroutine to asyncio.wait an error since 3.12?
    Because `wait` returns the exact objects it was given, split into `done` and `pending`. When it silently wrapped coroutines, callers received Tasks they had never created and could not match against their own inputs, which made the result sets nearly useless and led to leaked, uncancellable work. Requiring `asyncio.create_task` up front makes the handles yours from the start.
  • How do you get the winner's value out of the done set safely?
    Take a task from `done` and call `.result()`, guarding it — the first task to complete may have completed by raising, and `wait` will not have told you. If several may be done at once, iterate `done` and use `.exception()` to separate failures from successes rather than letting the first `.result()` throw and abandon the rest of the set uninspected.

saying these in an interview costs you the question

  • Expects asyncio.wait to raise a failing task's exception
  • Thinks wait's timeout cancels the unfinished tasks
  • Assumes done and pending come back in completion order
  • Passes bare coroutines to asyncio.wait
  • Leaves the pending tasks running after taking the winner
  • Believes as_completed yields results in submission order

context