Skip to content

Typing TaskGroup and gather Results

Fan-out code is where async types are most useful and most easily lost. asyncio.gather is typed with overloads that give each result its own type — but only up to a point — and return_exceptions=True widens every result to include exceptions that have to be narrowed before use. Checked with mypy 2.4.0 (--strict) and pyright 1.1.414 on Python 3.14: gather over two to six awaitables produced a precise tuple such as tuple[int, str]; over seven, mypy fell back to list[object] and pyright to list[int | str | bytes]. With return_exceptions=True each position became T | BaseException, and narrowing with isinstance(r, Exception) still left int | BaseException — correctly, because a cancelled child came back as a CancelledError, which is not an Exception. TaskGroup tasks kept exact types (Task[int], result() as int) regardless of how many there were. This guide keeps fan-out results typed.

Prerequisites

1. Let gather type each position — up to six

For a fixed set of different calls, gather returns a tuple whose element types match the arguments:

async def a() -> int: return 1
async def b() -> str: return "x"
async def c() -> bytes: return b"y"

r2 = await asyncio.gather(a(), b())
# mypy and pyright: tuple[int, str]

r6 = await asyncio.gather(a(), b(), a(), b(), a(), b())
# both: tuple[int, str, int, str, int, str]

r7 = await asyncio.gather(a(), b(), c(), a(), b(), c(), a())
# mypy:    list[object]
# pyright: list[int | str | bytes]

Typeshed defines one overload per arity from one to six, and a catch-all for more. Past six, mypy joins the element types to their common base — object here, which makes every element useless without a cast — while pyright keeps a union. Unpacking n, s = await gather(a(), b()) gives n: int and s: str directly. If a fan-out of different calls grows beyond six, split it into two gathers or switch to a TaskGroup, which has no arity limit.

Verify: reveal_type on each gather with heterogeneous arguments shows a tuple, not list[object].

What gather returned, by argument count A grid of 5 rows by 3 columns. What gather returned, by argument count call mypy 2.4.0 pyright 1.1.414 gather(a(), b()) tuple[int, str] tuple[int, str] gather of 6 mixed 6-element tuple 6-element tuple gather of 7 mixed list[object] list[int|str|bytes] gather(*[a() for ...]) list[int] list[int] gather(a(), b(), return_exceptions=True) tuple[int|BaseException, str|BaseException] same The six-argument limit comes from typeshed's overloads, not from asyncio.

2. Type homogeneous fan-outs as lists

The common case is many calls of the same function. A starred list of coroutines produces a list of one type in both checkers:

many = await asyncio.gather(*[fetch_user(i) for i in ids])
# list[User]

async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(fetch_user(i)) for i in ids]
users = [t.result() for t in tasks]
# list[User]

Both forms were inferred as list[int] for the test function in both checkers. The TaskGroup form keeps the task objects, typed Task[User], which matters when you want to match results back to inputs or check t.cancelled() afterwards. Calling t.result() after the async with block is safe: the block only exits once every task has finished, and if any failed, the block raised instead. Pairing results with their inputs is then a typed dict(zip(ids, users)) of dict[int, User], because gather and the task list both preserve the order of the inputs, whatever order the calls finished in.

Verify: the inferred type of the collected results is the function's return type, not Any.

3. Narrow return_exceptions results with BaseException

return_exceptions=True puts exceptions into the result list instead of raising the first one, and the types say so:

re = await asyncio.gather(a(), b(), return_exceptions=True)
# tuple[int | BaseException, str | BaseException]

v = re[0] + 1
# mypy:    Unsupported operand types for + ("BaseException" and "int")
# pyright: Operator "+" not supported for types "int | BaseException" and "Literal[1]"

n, = await asyncio.gather(a(), return_exceptions=True)
if isinstance(n, Exception):
    raise n
reveal_type(n)        # still int | BaseException in both checkers

The checkers are right to refuse the Exception narrowing. At runtime, cancelling one task inside such a gather produced the result list [CancelledError, int], and isinstance(r, Exception) was False for the CancelledError — since Python 3.8 it derives from BaseException. Narrow with BaseException, and decide deliberately what to do with cancellation:

results = await asyncio.gather(*[fetch(i) for i in ids], return_exceptions=True)
ok = [r for r in results if not isinstance(r, BaseException)]
# list[int] in both checkers

for r in results:
    if isinstance(r, asyncio.CancelledError):
        raise r                                  # do not swallow cancellation
    if isinstance(r, BaseException):
        log.warning("item failed: %r", r)

Verify: no code narrows return_exceptions results with Exception alone.

How should a return_exceptions result be narrowed? A decision on What is this element of the result with 4 outcomes. How should a return_exceptions result be narrowed? What is this element of the result? asyncio.CancelledError re-raise it never swallow cancellation other BaseException a failure log, collect, retry anything else the typed result T after narrowing narrowed with Exception only still T or BaseException CancelledError is not an Exception Checked at runtime: a cancelled child appeared in the results as CancelledError.

4. Keep heterogeneous TaskGroup results typed

For different calls, keep one variable per task. Each task carries its own type through the block:

async with asyncio.TaskGroup() as tg:
    user_t = tg.create_task(fetch_user(uid))       # Task[User]
    orders_t = tg.create_task(fetch_orders(uid))   # Task[list[Order]]
    prefs_t = tg.create_task(fetch_prefs(uid))     # Task[Prefs]

page = render(user_t.result(), orders_t.result(), prefs_t.result())

Measured: ta.result() revealed int and tb.result() str in both checkers, with no limit on how many tasks the group holds. This is the typed replacement for a long heterogeneous gather, and it brings TaskGroup's failure semantics with it: one failure cancels the siblings and the block raises an ExceptionGroup. asyncio.wait is the weak spot — with tasks of two types, mypy typed done as set[Task[object]] and pyright as set[Task[int] | Task[str]] — so prefer keeping the task variables over reading results out of done.

Verify: each result read after the block has the type of its function, with no casts.

5. Check the other combinators

The remaining combinators were checked the same way:

first = await asyncio.wait_for(a(), timeout=1)                     # int
pair = await asyncio.wait_for(asyncio.gather(a(), b()), timeout=1) # tuple[int, str]

async with asyncio.timeout(1):
    v = await a()                                                  # int

for coro in asyncio.as_completed([a(), b()]):
    x = await coro
    # mypy: object       pyright: int | str

wait_for and asyncio.timeout preserve types exactly. as_completed over mixed types loses them in mypy (object) and keeps a union in pyright — results arrive in completion order, so there is no position to attach a type to. When the results of as_completed need distinct handling, attach the identity to each coroutine, for example by returning (key, value) tuples, as in processing results in completion order.

Verify: loops over as_completed with mixed types narrow each result before use.

Combinators that kept exact result types (of 7 forms checked) 2 horizontal bars comparing pyright 1.1.414: exact or union with the others. Combinators that kept exact result types (of 7 forms checked) pyright 1.1.414: exact or union 7 of 7 mypy 2.4.0: exact 5 of 7 Forms: gather of 2, of 6, of 7, starred gather, TaskGroup tasks, wait_for, as_completed. mypy's misses: gather of 7 and as_completed. Most fan-out forms are precisely typed; the exceptions are worth knowing.

Verification

Fan-out results stay typed when:

  • Heterogeneous gathers have six awaitables or fewer, or are rewritten as TaskGroups with one variable per task.
  • Homogeneous fan-outs reveal list[T].
  • return_exceptions results are narrowed with BaseException, and CancelledError is re-raised.
  • asyncio.wait and as_completed results are narrowed or keyed before use.

Diagnostic Hook: search mypy output (or a reveal_type sweep) for list[object] and Task[object]. Both are the fingerprints of a combinator that lost its types — usually a gather that grew past six arguments or an asyncio.wait over mixed tasks.

Pitfalls & edge cases

  • The seventh argument. Measured: mypy's result type became list[object].
  • Narrowing with Exception. CancelledError is a BaseException; the type stays wide, correctly.
  • Swallowing cancellation. Filtering out BaseException results silently drops cancellations.
  • Reading results from done. Mixed tasks come back as Task[object] in mypy.

Frequently Asked Questions

What type does asyncio.gather return?

For one to six awaitables, a tuple with one element type per argument, such as tuple[int, str]. For seven or more, mypy 2.4 returned list[object] and pyright list of the union. A starred list of one function's coroutines returns list[T].

How do I type gather results with return_exceptions=True?

Each element becomes T | BaseException. Narrow with isinstance(r, BaseException), not Exception, because a cancelled child comes back as CancelledError, which is not an Exception.

Does TaskGroup preserve result types?

Yes. tg.create_task(coro) returns Task[T] and task.result() is T, with no limit on the number of tasks, which makes it the typed alternative to a long heterogeneous gather.

Why does mypy say object for asyncio.as_completed?

With awaitables of different types, results arrive in completion order and mypy joins them to object; pyright keeps a union. Return (key, value) pairs or narrow each result.