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¶
- Python 3.11+ for
TaskGroup;pip install mypy pyright. - Structured fan-out, from structured concurrency with asyncio.TaskGroup.
- Coroutine typing basics, from typing coroutine functions.
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].
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.
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.
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_exceptionsresults are narrowed withBaseException, andCancelledErroris re-raised.asyncio.waitandas_completedresults 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.CancelledErroris aBaseException; the type stays wide, correctly. - Swallowing cancellation. Filtering out
BaseExceptionresults silently drops cancellations. - Reading results from
done. Mixed tasks come back asTask[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.
Related¶
- Typing Async Code — up to the topic overview.
- Typing coroutine functions — Awaitable versus Coroutine.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.