Python

Python Asyncio Task Groups vs Gather for Concurrent API Calls

5 min read by DebuggedIt

Quick answer

asyncio.TaskGroup, available in Python 3.11 and later, runs tasks concurrently and cancels the remaining ones if any task fails, giving structured error...

asyncio.TaskGroup, available in Python 3.11 and later, runs tasks concurrently and cancels the remaining ones if any task fails, giving structured error handling. asyncio.gather is older and more flexible with return values, but its failure behavior is easier to get wrong. For concurrent API calls, TaskGroup is usually the safer default when a failure should stop the whole operation.

How Does asyncio.gather Behave?

gather takes several awaitables, runs them concurrently, and returns their results in the same order. By default, if one of them raises an exception, gather propagates the first exception to the caller, but it does not cancel the other tasks. They keep running in the background, and their results are lost.

results = await asyncio.gather(
    fetch_user(1),
    fetch_orders(1),
    fetch_prefs(1),
)

This leftover work is the main hazard. A failed request can leave other requests running, holding connections and consuming resources, with nobody awaiting their outcome. You can pass return_exceptions=True so that exceptions are returned as values in the results list instead of raised, which lets every call finish but forces you to check each result yourself.

How Does TaskGroup Change Things?

TaskGroup is an async context manager that owns the tasks created inside it. When the block exits, it waits for all tasks to finish. If any task fails with an exception, the group cancels the others and raises an exception group containing the errors.

async with asyncio.TaskGroup() as tg:
    user_task = tg.create_task(fetch_user(1))
    orders_task = tg.create_task(fetch_orders(1))
    prefs_task = tg.create_task(fetch_prefs(1))

user = user_task.result()
orders = orders_task.result()
prefs = prefs_task.result()

This is structured concurrency: the lifetime of the tasks is tied to a block of code, so nothing leaks out. After the block, all tasks are either complete or canceled, which makes reasoning about resources much simpler.

Failures arrive as an ExceptionGroup, which you handle with the except star syntax. This lets you handle different exception types separately, even when several tasks failed at once. Check the Python documentation for the exact semantics in your version.

Which One Should You Use for API Calls?

The choice depends on what a partial failure means for your operation.

  • All results are required: use TaskGroup. If one call fails, the others are cancelled and the error surfaces immediately.
  • Partial results are acceptable: use gather with return_exceptions=True, or catch exceptions inside each task and return a result object.
  • Older Python versions: gather is the only built-in option before 3.11, though third-party libraries provide similar structured concurrency.
  • Dynamic number of tasks: both work, but TaskGroup allows creating tasks as you go inside the block.

A useful pattern for tolerant fan-out with TaskGroup is to wrap each call in a function that catches expected errors and returns a value, so one failing dependency does not cancel the group. Reserve exceptions for failures that should abort everything.

Consider what the caller sees in each case. With a page that combines a user profile, orders, and recommendations, losing the recommendations may be acceptable, while losing the profile is not. Encoding that decision explicitly, instead of leaving it to default exception behavior, makes the code easier to review and the failures easier to explain.

How Do You Limit Concurrency and Add Timeouts?

Launching hundreds of requests at once can overwhelm the remote service or exhaust local connections. Use a semaphore to cap how many run simultaneously.

sem = asyncio.Semaphore(10)

async def limited_fetch(client, url):
    async with sem:
        return await client.get(url, timeout=5)

Apply timeouts at the call level and for the group as a whole. Python 3.11 added asyncio.timeout as a context manager, which works naturally around a TaskGroup block. Retries deserve care too: retry inside each task with backoff, and make sure retried requests are safe to repeat.

  • Set per-request timeouts so one slow call cannot stall the batch.
  • Set an overall deadline for the whole operation.
  • Use connection pooling in your HTTP client, and reuse a single client across calls.
  • Log failures with enough context to identify which call failed.

Frequently Asked Questions

Does gather cancel other tasks when one fails?

No, not by default. The others continue running, which can leak work unless you handle it explicitly.

What is an ExceptionGroup?

A container for several exceptions raised together, introduced in Python 3.11. You handle it with the except star syntax.

Can I get results in order with TaskGroup?

Yes. Keep references to the tasks and call result on each after the block, in whatever order you need.

Should I use TaskGroup for every concurrent operation?

For most new code on Python 3.11 or later, yes. Use gather when you need its specific return behavior or support older versions.

Summary

TaskGroup gives structured concurrency with automatic cancellation, while gather offers flexible results but needs care with failures. Add semaphores and timeouts for API calls. Check the asyncio documentation for your Python version.