asyncio.gather vs asyncio.wait vs asyncio.TaskGroup
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
Python's asyncio module provides three primary tools for running multiple coroutines concurrently: asyncio.gather, asyncio.wait, and asyncio.TaskGroup. Each serves a different use case. gather is the simplest and returns ordered results. wait gives you fine-grained control over completion conditions. TaskGroup (Python 3.11+) enforces structured concurrency where all tasks are guaranteed to finish before the context exits.
Choosing the wrong one leads to either lost exceptions, orphaned tasks, or unnecessarily complex code. This article breaks down exactly when to use each.
asyncio.gather: Simple Aggregation
asyncio.gather takes multiple coroutines or futures, runs them concurrently, and returns a list of results in the same order they were passed in.
The results list always matches the input order, regardless of which coroutine finished first. This makes gather ideal for "fan-out, collect all" patterns like fetching multiple API endpoints in parallel.
Exception Behavior
By default, if any coroutine raises an exception, gather propagates it immediately. The other coroutines continue running in the background, but their results are lost:
Setting return_exceptions=True changes the behavior: exceptions are returned as values in the results list instead of being raised:
asyncio.wait: Fine-Grained Control
asyncio.wait returns two sets of tasks (done and pending) based on a completion condition. It accepts asyncio.Task objects or futures, not bare coroutines (in Python 3.11+ bare coroutines raise a deprecation warning).
Completion Conditions
| Condition | Behavior |
asyncio.ALL_COMPLETED | Wait for every task to finish (default) |
asyncio.FIRST_COMPLETED | Return as soon as any one task finishes |
asyncio.FIRST_EXCEPTION | Return when the first task raises an exception, or when all complete if none raise |
asyncio.wait also accepts a timeout parameter. If the timeout expires, whatever has completed goes into done and everything else goes into pending:
Key Difference from gather
asyncio.wait does not raise exceptions automatically. You must call task.result() on each completed task to retrieve the result or re-raise the exception. This gives you full control but requires more careful handling.
asyncio.TaskGroup: Structured Concurrency
Introduced in Python 3.11, TaskGroup enforces a critical guarantee: all tasks created within the group are awaited before the async with block exits. If any task raises an exception, all remaining tasks are cancelled and the exception is re-raised as an ExceptionGroup.
Exception Handling with ExceptionGroup
When a task inside a TaskGroup fails, the group cancels all other tasks, waits for cancellation to finish, then raises an ExceptionGroup:
The except* syntax (Python 3.11+) matches specific exception types within the group. This prevents the common problem with gather where one failure silently orphans other tasks.
Comparison Table
| Feature | asyncio.gather | asyncio.wait | asyncio.TaskGroup |
| Python version | 3.4+ | 3.4+ | 3.11+ |
| Input type | Coroutines or futures | Tasks or futures | Created via tg.create_task() |
| Result order | Preserved (matches input) | Unordered sets | Access via task.result() |
| Exception default | Propagates first exception | No auto-propagation | Cancels all, raises ExceptionGroup |
| Partial completion | Not supported | FIRST_COMPLETED, timeout | Not supported |
| Orphaned task risk | Yes, on exception | Yes, if pending not cancelled | No, structured guarantee |
| Cancellation | Cancels all if gather cancelled | Manual per-task | Automatic on exception |
| Best for | Fan-out, collect all results | Race conditions, timeouts | Safety-critical concurrent work |
Decision Guide
Use asyncio.gather when you need all results in order and failures are either unlikely or acceptable to handle with return_exceptions=True. This covers the majority of "call N endpoints in parallel" scenarios.
Use asyncio.wait when you need partial results, race semantics (first to respond wins), or timeout-based completion. It is the right tool for health checks, competitive fetches, or progress monitoring.
Use asyncio.TaskGroup when task cleanup matters. Any code that creates background tasks inside a request handler, a transaction, or a resource scope should use TaskGroup to prevent orphaned coroutines that leak memory or database connections.
Common Pitfalls
- Passing bare coroutines to
asyncio.waitinstead of wrapping them withasyncio.create_task(). In Python 3.11+ this produces a deprecation warning, and in future versions it will be an error. - Using
asyncio.gatherwithoutreturn_exceptions=Trueand losing results from tasks that completed successfully when one task fails. - Forgetting to cancel pending tasks after
asyncio.waitwithFIRST_COMPLETED. The pending tasks keep running in the background, consuming resources. - Catching
Exceptioninstead of usingexcept*withTaskGroup. AnExceptionGroupis not a single exception, and a bareexcept Exceptionwill catch it but lose the structured information. - Using
TaskGroupon Python versions below 3.11. Theexceptiongroupbackport package exists, butTaskGroupitself requires 3.11+.
Summary
asyncio.gatheris the default choice for running coroutines in parallel and collecting ordered results. Usereturn_exceptions=Truewhen partial success is acceptable.asyncio.waitprovides control over completion conditions (first done, first exception, timeout). Always cancel pending tasks when you no longer need them.asyncio.TaskGroupis the safest option for structured concurrency. It guarantees no orphaned tasks and usesExceptionGroupfor clean error reporting.- For new code targeting Python 3.11+, prefer
TaskGroupovergatherwhenever task lifecycle management matters.

