TL;DR

Use asyncio.TaskGroup when a set of tasks should succeed or fail together: the first failure cancels the siblings and every error reaches you in an ExceptionGroup. Use asyncio.gather when you want results in input order and you’re fine with partial failure, and in that case pass return_exceptions=True. Plain gather() with the default arguments sits in a risky middle: in the runs below, it raised the first error while the other coroutines kept running in the background, and a second error from one of them vanished without a log line, even under python -X dev. Speed doesn’t separate them: at 100,000 tasks the two APIs came within 8% of each other, and the order flipped between task counts.

FactValueSourceVerified
TaskGroup addedPython 3.11asyncio docsOct 22, 2026
TaskGroup.cancel() addedPython 3.15 (released Oct 9, 2026)What’s New in 3.15Oct 22, 2026
python -m asyncio ps / pstreePython 3.14What’s New in 3.14Oct 22, 2026
Interpreter used for every run belowCPython 3.12.3this article’s sandboxOct 22, 2026

Why this comparison keeps coming up

asyncio.gather has been the default way to run coroutines concurrently since Python 3.4. TaskGroup arrived in 3.11 as asyncio’s take on the “nursery” from Trio: a block that owns its child tasks and doesn’t exit until every one of them is done. The asyncio docs now point people at it, saying under gather that TaskGroup “provides stronger safety guarantees than gather” because it cancels the remaining tasks when one raises, “while gather will not.”

That wording has its critics. In a July 2026 thread on discuss.python.org proposing a GatheringTaskGroup, one participant argued that cancel-on-error isn’t a safety guarantee at all, just “a different behavioral choice.” I think that’s the right way to frame it, and it’s why this post runs both APIs against the same failures instead of declaring a winner up front. Every block of output below was produced by running the script above it on CPython 3.12.3. The one exception is the 3.15-only TaskGroup.cancel(), which the sandbox can’t run; that’s flagged where it appears.

There’s also a fresh reason to revisit the question. Python 3.15 shipped on October 9, 2026 with TaskGroup.cancel(), which removes the clumsiest workaround TaskGroup users had to write. More on that further down.

TaskGroup vs gather at a glance

Behaviorgather() (default)gather(return_exceptions=True)TaskGroup
One child raisesFirst exception propagates immediatelyException stored in the result listSiblings cancelled, then ExceptionGroup raised
Other children after a failureKeep running, unsupervisedKeep running, results collectedCancelled
Second, later errorSilently droppedIn the result listCaptured only if it happens before cancellation lands
ResultsList in input orderList in input order, exceptions mixed intask.result() on each task after the block
Outer cancellationCancels all childrenCancels all childrenCancels all children
Stop early on successCancel the gather futureSameCustom exception (3.11-3.14) or tg.cancel() (3.15+)
Minimum Python3.43.43.11

The rest of the article is the evidence behind each row.

What happens when one task fails

Here’s the core experiment. Three workers start together; a fails after 0.1 seconds, b would finish at 0.5 and c at 1.0. Each worker logs whether it finished or got cancelled.

import asyncio
import time

T0 = time.perf_counter()


def log(msg):
    print(f"[{time.perf_counter() - T0:4.2f}s] {msg}")


async def worker(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ValueError(f"{name} failed")
        log(f"{name} finished")
        return name
    except asyncio.CancelledError:
        log(f"{name} cancelled")
        raise


async def with_gather():
    log("--- gather ---")
    try:
        await asyncio.gather(
            worker("a", 0.1, fail=True),
            worker("b", 0.5),
            worker("c", 1.0),
        )
    except ValueError as e:
        log(f"caught {e!r}")
    # give the orphaned siblings time to show they are still alive
    await asyncio.sleep(1.2)


async def with_taskgroup():
    log("--- TaskGroup ---")
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(worker("a", 0.1, fail=True))
            tg.create_task(worker("b", 0.5))
            tg.create_task(worker("c", 1.0))
    except* ValueError as eg:
        log(f"caught {eg!r}")


async def main():
    global T0
    await with_gather()
    T0 = time.perf_counter()
    await with_taskgroup()


asyncio.run(main())

Running it prints:

[0.00s] --- gather ---
[0.10s] caught ValueError('a failed')
[0.50s] b finished
[1.00s] c finished
[0.00s] --- TaskGroup ---
[0.10s] b cancelled
[0.10s] c cancelled
[0.10s] caught ExceptionGroup('unhandled errors in a TaskGroup', [ValueError('a failed')])

Read the gather half carefully. The except ValueError handler ran at 0.10s, so as far as the calling code knows, the operation is over. Yet b and c kept going and finished 0.4 and 0.9 seconds later. If those coroutines write to a database or charge a card, they do it after your error handler has already decided the request failed. Nothing awaits them anymore, and if the event loop shuts down first, they’re simply abandoned mid-flight.

The TaskGroup half ends at 0.10s for everyone. Both siblings got CancelledError at the moment a failed, the async with block waited for them to unwind, and only then did the ExceptionGroup reach the caller. When the except* handler runs, no child of that group is still alive. That’s the property people mean by “structured concurrency”: a task’s lifetime can’t escape the block that started it.

The error gather throws away

The default gather() has a second, quieter problem. Once it has propagated the first exception, it has nowhere to send a later one. This script makes a second coroutine fail after the first:

import asyncio


async def step(name, delay, exc=None):
    await asyncio.sleep(delay)
    if exc:
        raise exc
    print(f"{name} done")


async def main():
    try:
        await asyncio.gather(
            step("charge-card", 0.1, ValueError("card declined")),
            step("write-audit-log", 0.3, PermissionError("audit DB read-only")),
            step("send-email", 0.2),
        )
    except ValueError as e:
        print("handler saw:", repr(e))
    await asyncio.sleep(0.5)
    print("main exiting; the PermissionError was never reported")

    # Outer cancellation does propagate into gather's children.
    kids = [asyncio.create_task(step(n, 1)) for n in ("x", "y")]
    g = asyncio.gather(*kids)
    asyncio.get_running_loop().call_later(0.05, g.cancel)
    try:
        await g
    except asyncio.CancelledError:
        print("outer gather cancelled; children cancelled:",
              [k.cancelled() for k in kids])


asyncio.run(main())

Run with python3 -X dev, which turns on asyncio debug mode and makes unretrieved task exceptions louder:

handler saw: ValueError('card declined')
send-email done
main exiting; the PermissionError was never reported
outer gather cancelled; children cancelled: [True, True]

There’s no “Task exception was never retrieved” warning anywhere in that output. gather attaches a done-callback to each child that consumes its exception, so the PermissionError from the audit-log step is marked as retrieved and then discarded. In production, that’s how an audit write can fail for weeks without anyone noticing, because the only error anyone saw was the card decline.

The last line shows the part of gather that does behave in a structured way: cancelling the outer gather future cancels every child that hasn’t finished. The asyncio documentation also covers the reverse case. If one child is cancelled on its own, gather treats it as if it had raised CancelledError and doesn’t cancel the gather itself, so one task’s cancellation can’t spread to its siblings.

Multiple failures: ExceptionGroup and except*

TaskGroup reports errors as an ExceptionGroup, and you pick them apart with except* (also new in 3.11, from PEP 654). Each except* clause receives the subset of the group that matches its type, and more than one clause can run for a single raise. This script fires six fake fetches, two of which fail at different times, then a second scenario where two tasks fail in the same event-loop tick:

import asyncio


async def fetch(i):
    await asyncio.sleep(0.01 * i)
    if i == 2:
        raise ConnectionError(f"host {i} refused")
    if i == 4:
        raise TimeoutError(f"host {i} timed out")
    return f"payload-{i}"


async def gather_default():
    try:
        await asyncio.gather(*(fetch(i) for i in range(6)))
    except Exception as e:
        print("gather (default) raised:", repr(e))


async def gather_return_exceptions():
    results = await asyncio.gather(*(fetch(i) for i in range(6)),
                                   return_exceptions=True)
    print("gather(return_exceptions=True) ->")
    for r in results:
        print("   ", repr(r))


async def taskgroup_all_errors():
    try:
        async with asyncio.TaskGroup() as tg:
            for i in range(6):
                tg.create_task(fetch(i))
    except* ConnectionError as eg:
        print("except* ConnectionError:", [str(e) for e in eg.exceptions])
    except* TimeoutError as eg:
        print("except* TimeoutError:  ", [str(e) for e in eg.exceptions])


async def taskgroup_concurrent_errors():
    # two tasks that fail at the same instant: both land in the group
    async def boom(exc):
        await asyncio.sleep(0.05)
        raise exc
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(boom(ConnectionError("db down")))
            tg.create_task(boom(TimeoutError("cache slow")))
            tg.create_task(asyncio.sleep(5))
    except* ConnectionError as eg:
        print("same-tick ConnectionError:", [str(e) for e in eg.exceptions])
    except* TimeoutError as eg:
        print("same-tick TimeoutError:   ", [str(e) for e in eg.exceptions])


async def main():
    await gather_default()
    await gather_return_exceptions()
    await taskgroup_all_errors()
    await taskgroup_concurrent_errors()


asyncio.run(main())

Output:

gather (default) raised: ConnectionError('host 2 refused')
gather(return_exceptions=True) ->
    'payload-0'
    'payload-1'
    ConnectionError('host 2 refused')
    'payload-3'
    TimeoutError('host 4 timed out')
    'payload-5'
except* ConnectionError: ['host 2 refused']
same-tick ConnectionError: ['db down']
same-tick TimeoutError:    ['cache slow']

Look at what’s missing: there’s no except* TimeoutError line for the first TaskGroup run. Host 4’s TimeoutError never happened, because host 2 failed at 20 ms and the group cancelled host 4 before it reached its own failure at 40 ms. “TaskGroup collects all the errors” is true only in a narrow sense: it collects every error raised before cancellation takes effect. Two tasks that fail in the same tick, as in the last scenario, both make it into the group, and both except* clauses run. A task that would have failed later is cancelled instead, and you never learn about it.

If you need the full picture of which inputs fail (a batch validation job, a health check across 50 hosts), that’s the job gather(..., return_exceptions=True) does well. Every slot in the result list holds either a value or the exception for that input, in input order, and nothing gets cancelled.

One syntax trap with except*: you can’t return, break or continue inside the clause. Python rejects it at compile time:

SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block

Set a variable in the handler and return after the try statement instead.

Getting results out of a TaskGroup

This is the complaint that started the GatheringTaskGroup thread, and it’s fair. gather hands you a list; TaskGroup hands you nothing, so you keep references to the tasks yourself:

import asyncio


async def price(symbol, delay):
    await asyncio.sleep(delay)
    return symbol, round(100 * delay, 2)


async def results_gather():
    pairs = await asyncio.gather(price("EUR", 0.03), price("USD", 0.01),
                                 price("GBP", 0.02))
    print("gather results (input order):", pairs)


async def results_taskgroup():
    async with asyncio.TaskGroup() as tg:
        tasks = {s: tg.create_task(price(s, d))
                 for s, d in [("EUR", 0.03), ("USD", 0.01), ("GBP", 0.02)]}
    print("TaskGroup results:", {s: t.result() for s, t in tasks.items()})


asyncio.run(results_gather())
asyncio.run(results_taskgroup())
gather results (input order): [('EUR', 3.0), ('USD', 1.0), ('GBP', 2.0)]
TaskGroup results: {'EUR': ('EUR', 3.0), 'USD': ('USD', 1.0), 'GBP': ('GBP', 2.0)}

Both preserve the mapping from input to result even though USD finished first. The TaskGroup version takes one more line, and in exchange you can key results by whatever you like (a dict by symbol here) instead of relying on list positions. Calling .result() is safe after the async with block exits normally, because by then every task is done. Don’t call it inside the block; a task that hasn’t finished raises InvalidStateError.

That ergonomics gap is what the GatheringTaskGroup proposal wanted to close with a tg.results() method. The thread didn’t end in an accepted change: Joao S. O. Bueno replied “I don’t see this kind of change coming to the stdlib,” and pointed to gather and asyncio.wait for result-oriented code. For now the dict-of-tasks pattern is the idiom.

Timeouts and bounded concurrency

TaskGroup has no timeout parameter. You wrap it in asyncio.timeout() (3.11+), which cancels the enclosing task when the deadline passes, and the group then cancels its children on the way out. Both functions in this section live in the same script as price() above, with import time added:

async def deadline():
    t0 = time.perf_counter()
    try:
        async with asyncio.timeout(0.2):
            async with asyncio.TaskGroup() as tg:
                fast = tg.create_task(price("fast", 0.05))
                slow = tg.create_task(price("slow", 2.0))
    except TimeoutError:
        dt = time.perf_counter() - t0
        print(f"timeout after {dt:.2f}s; fast.done()={fast.done()} "
              f"fast.result()={fast.result()} slow.cancelled()={slow.cancelled()}")
timeout after 0.20s; fast.done()=True fast.result()=('fast', 5.0) slow.cancelled()=True

The timeout fired at 0.20s, the slow task was cancelled, and the fast task’s result is still readable afterward. The timeout surfaces as a plain TimeoutError, not wrapped in an ExceptionGroup, because it’s raised by the asyncio.timeout() context manager outside the group, so an ordinary except clause catches it. The gather equivalent is asyncio.wait_for(asyncio.gather(...), timeout), which also works but loses the results of tasks that did finish.

Neither API limits how many tasks run at once. If you’re fanning out 10,000 HTTP calls, put a semaphore inside the coroutine:

async def bounded(n_jobs=20, limit=5):
    sem = asyncio.Semaphore(limit)
    running = peak = 0

    async def job(i):
        nonlocal running, peak
        async with sem:
            running += 1
            peak = max(peak, running)
            await asyncio.sleep(0.02)
            running -= 1
            return i * i

    t0 = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(job(i)) for i in range(n_jobs)]
    total = sum(t.result() for t in tasks)
    print(f"{n_jobs} jobs, limit {limit}: peak concurrency={peak}, "
          f"sum={total}, wall={time.perf_counter() - t0:.2f}s")
20 jobs, limit 5: peak concurrency=5, sum=2470, wall=0.08s

Twenty jobs of 20 ms each, five at a time, finish in four waves: 0.08 seconds. The same semaphore pattern works with gather. It caps concurrency, but it doesn’t cap memory, since all 20 task objects exist from the start. For a very large fan-out, feed a fixed pool of workers from an asyncio.Queue instead. The same “count what’s in flight” thinking shows up on the server side in FastAPI rate limiting, where an asyncio.Lock guards the token bucket.

Stopping early: the 3.12 workaround and 3.15’s TaskGroup.cancel()

Sometimes the group’s goal is met before every task finishes: the first mirror that answers wins, and the others should stop. Before 3.15, TaskGroup had no method for that. The workaround, which the 3.15 release notes call “unintuitive boilerplate,” is to raise a private exception and swallow it outside the block:

import asyncio
import random
import sys


class Found(Exception):
    """Raised to stop the group once one mirror answers (pre-3.15 idiom)."""


async def probe(mirror, delay):
    try:
        await asyncio.sleep(delay)
        return mirror
    except asyncio.CancelledError:
        print(f"  {mirror} cancelled")
        raise


async def first_mirror(mirrors):
    winner = None

    async def race(mirror, delay):
        nonlocal winner
        winner = await probe(mirror, delay)
        raise Found

    try:
        async with asyncio.TaskGroup() as tg:
            for m, d in mirrors:
                tg.create_task(race(m, d))
    except* Found:
        pass
    return winner


async def main():
    print(sys.version.split()[0])
    random.seed(7)
    mirrors = [(f"mirror-{i}", random.uniform(0.05, 0.5)) for i in range(4)]
    print("delays:", {m: round(d, 3) for m, d in mirrors})
    print("winner:", await first_mirror(mirrors))


asyncio.run(main())
3.12.3
delays: {'mirror-0': 0.196, 'mirror-1': 0.118, 'mirror-2': 0.343, 'mirror-3': 0.083}
  mirror-0 cancelled
  mirror-1 cancelled
  mirror-2 cancelled
winner: mirror-3

It works, and the fastest mirror (83 ms) wins while the other three are cancelled. But it’s a misuse of exceptions for control flow, and the except* Found: pass line can hide a real bug if some other code path raises Found.

Python 3.15 adds TaskGroup.cancel(), contributed by John Belmonte (gh-127214). The docs describe it as “a non-exceptional, early exit of the task group’s lifetime”: it cancels every unfinished task in the group plus the body, and the async with exits without raising CancelledError. It’s idempotent, and it’s safe to call after the group has already exited. The same race becomes:

# Python 3.15+ only. Not executed here: the sandbox runs 3.12.
async def first_mirror(mirrors):
    winner = None
    async with asyncio.TaskGroup() as tg:
        async def race(mirror, delay):
            nonlocal winner
            winner = await probe(mirror, delay)
            tg.cancel()
        for m, d in mirrors:
            tg.create_task(race(m, d))
    return winner

The sentinel exception and the except* clause are gone. That snippet is written against the 3.15 docs and wasn’t run here, so treat it as a sketch. If you’re targeting 3.11 through 3.14, keep the Found pattern. If you only need “first result wins” and no structured cleanup, asyncio.wait(tasks, return_when=FIRST_COMPLETED) followed by cancelling the pending set works on any version.

The 3.15 release has more in it than this one method; the Python 3.15 overview covers lazy imports, frozendict and the JIT numbers.

Does TaskGroup cost anything? Overhead, measured

A reasonable worry is that TaskGroup’s bookkeeping slows down large fan-outs. This benchmark spawns N no-op tasks (each does one await asyncio.sleep(0)) through three paths: gather on bare coroutines, gather on pre-created tasks, and a TaskGroup. Each cell is the median of seven runs, including asyncio.run setup, on CPython 3.12.3 in a shared sandbox:

import asyncio, statistics, time

async def noop():
    await asyncio.sleep(0)

async def via_gather(n):
    await asyncio.gather(*(noop() for _ in range(n)))

async def via_gather_tasks(n):
    await asyncio.gather(*[asyncio.create_task(noop()) for _ in range(n)])

async def via_taskgroup(n):
    async with asyncio.TaskGroup() as tg:
        for _ in range(n):
            tg.create_task(noop())

def bench(fn, n, reps=7):
    times = []
    for _ in range(reps):
        t0 = time.perf_counter()
        asyncio.run(fn(n))
        times.append(time.perf_counter() - t0)
    return statistics.median(times)

for n in (1_000, 10_000, 100_000):
    print(n, [f"{bench(fn, n) * 1000:.1f} ms"
              for fn in (via_gather, via_gather_tasks, via_taskgroup)])
Tasksgather(coros)gather(tasks)TaskGroup
1,0008.1 ms8.1 ms8.3 ms
10,000118.2 ms108.7 ms97.2 ms
100,0001,847.8 ms1,958.9 ms1,810.0 ms

The ordering flips between rows, which tells you the differences are noise. At 100,000 tasks, all three land within about 8% of each other, and the cost is dominated by creating and scheduling task objects, which every path has to do (gather wraps bare coroutines in tasks internally). Pick based on failure semantics. If you’re squeezing CPU out of an async service, the hot spot is almost never task creation; a profiler will tell you where it actually is, and the Python sampling profiler walkthrough shows how to read async-aware stacks.

Migrating gather to TaskGroup without surprises

A CPython docs commit was written specifically to “rule out the implication” that TaskGroup is a drop-in replacement for gather, and the experiments above show why. A mechanical rewrite changes three behaviors at once. Before you migrate a call site, check:

  1. Callers that catch a specific exception. except ValueError around a gather becomes except* ValueError around a TaskGroup, because the error now arrives wrapped in an ExceptionGroup. A plain except ValueError won’t match the group. Code further up the stack that catches ValueError also stops matching, which is the change that breaks tests.
  2. Code that relied on siblings finishing. If any child had a side effect you counted on completing even when another child failed, TaskGroup will cancel it. Make that work its own task outside the group, or shield it explicitly.
  3. Call sites using return_exceptions=True. Leave them alone. That’s the case where gather does exactly what you want, and TaskGroup has no equivalent mode.

Python 3.10 has no TaskGroup, but AnyIO’s task groups provide the same structured semantics on top of asyncio, and AnyIO is what Starlette builds on. Current AnyIO 4.x requires Python 3.10 or newer, so anything older is stuck with gather or an old AnyIO release.

For debugging the result, 3.14’s new python -m asyncio pstree PID prints a live tree of tasks, and the example in the 3.14 release notes is nested TaskGroups. It shows each task under the TaskGroup.__aexit__ frame that’s waiting for it, which is precisely the parent-child relationship that gather code doesn’t have.

Which one should you use?

SituationUseWhy
Several calls that all have to succeed (load a page’s data, a multi-step write)TaskGroupOne failure cancels the rest; no orphans
Batch job where you want every input’s outcomegather(..., return_exceptions=True)Nothing cancelled, one slot per input
Results needed as a positional list, failures fatal, Python ≥ 3.11TaskGroup + list of tasksOne extra line buys cancellation
First result winstg.cancel() on 3.15; Found idiom or asyncio.wait before thatExplicit early exit
Process results as they arriveasyncio.as_completedNeither API streams results
Python 3.10gather or AnyIO task groupsTaskGroup doesn’t exist

My default for new code is TaskGroup. The one-line cost of keeping task references buys the property a long-running service needs most: no coroutine outlives the request that started it. I reach for gather only with return_exceptions=True, where its semantics are honest about partial failure. Bare gather() is the one I’d remove from a codebase during review, because its failure mode is invisible.

If your async code sits behind a web framework, the choice of framework also shapes how much of this you write by hand; the FastAPI vs Litestar comparison covers what each one gives you out of the box.

FAQ

Is TaskGroup better than gather?

For tasks that must all succeed, yes: TaskGroup cancels the remaining tasks when one fails and raises every error it collected in an ExceptionGroup. gather is still the better tool when you want each input’s result or exception in order, using return_exceptions=True.

Does asyncio.gather cancel other tasks on exception?

No. With the default return_exceptions=False, gather propagates the first exception immediately and the other awaitables “won’t be cancelled and will continue to run,” per the Python docs. In the test above, two siblings finished 0.4 and 0.9 seconds after the error handler had already run.

How do I get results from an asyncio TaskGroup?

Keep the Task objects that tg.create_task() returns, in a list or dict, and call .result() on each after the async with block exits. By then every task is done, so .result() returns immediately.

How do I set a timeout for a TaskGroup?

Wrap the group in async with asyncio.timeout(seconds):. When the deadline passes, the group’s children are cancelled and a plain TimeoutError is raised outside the group. Tasks that already finished keep their results.

Is TaskGroup available in Python 3.10?

No, asyncio.TaskGroup was added in Python 3.11, together with except* and ExceptionGroup. On 3.10, AnyIO’s create_task_group() offers the same structured-concurrency behavior; current AnyIO releases don’t support anything older.

Sources

Bottom line

The two APIs make different promises. gather promises a list of results; TaskGroup promises that when the block exits, nothing it started is still running. The second promise is what keeps half-finished side effects and swallowed errors out of production, and Python 3.15’s TaskGroup.cancel() removes the most awkward pattern from using it. Keep gather(return_exceptions=True) for the batch jobs where partial failure is the expected outcome, and stop writing bare gather() in new code.