5 min read
asyncio TaskGroup and gather run concurrent awaits at the same speed (40 overlapping 10 ms waits took 0.011 s with TaskGroup and 0.0109 s with gather, about 38x faster than sequential awaits on Python 3.13.5), so choose between them on exception handling. When a task fails, TaskGroup raises an ExceptionGroup and cancels the sibling tasks, while gather either fails fast on the first error or collects exceptions in the results list with return_exceptions=True.
It is not another asyncio-vs-threads bake-off (see asyncio vs threads localhost lab) and not a kernel context-switch study. The question is narrowly: gather vs TaskGroup vs sequential awaits for overlapping waits.
Related links:
Lab honesty (1 Oct 2026 IST): Python 3.13.5. asyncio.sleep stands in for overlapping I/O waits (GIL released while sleeping). Affiliates: 0. No Docker. No ThreadPool arms.
Verdict up front (40× sleep(10 ms)): sequential ~0.415 s; gather ~0.0109 s (~38.15×); TaskGroup ~0.011 s (~37.87×). At 100× sleep(5 ms) both concurrent arms stay near the ~5 ms ideal (~79.3–83.63× vs sequential). gather ≈ TaskGroup for happy-path throughput.
| Arm | Pattern |
|---|---|
| sequential | for _ in range(N): await sleep(dt) |
asyncio.gather | await gather(*(sleep(dt) for _ in range(N))) |
asyncio.TaskGroup | async with TaskGroup() as tg: tg.create_task(...) |
gather + return_exceptions=True | same overlap; exception policy differs |
Script: lab-evidence/82-asyncio-gather-vs-taskgroup/results/run_lab.py.
| Arm | p50 s | vs sequential |
|---|---|---|
| sequential | 0.415 | 1.00× |
| gather | 0.0109 | 38.15× |
| TaskGroup | 0.011 | 37.87× |
| gather + return_exceptions | 0.0109 | 37.97× |
Ideal concurrent wall ≈ 0.010 s; both gather and TaskGroup land within ~10% of that.
| Arm | p50 s | vs sequential |
|---|---|---|
| sequential | 0.53 | 1.00× |
| gather | 0.0067 | 79.3× |
| TaskGroup | 0.0063 | 83.63× |
return_exceptions=True did not change throughput here (no failures); it changes error policy, not speed.On success they look the same in wall time. They diverge when tasks fail: TaskGroup raises an ExceptionGroup and cancels siblings; gather can collect exceptions with return_exceptions=True or fail-fast on the first error. Pick for semantics, not microseconds.
asyncio.sleep yields the event loop like waiting on sockets/timers. This post is about scheduling many overlapping awaits, not syscall fidelity. Real TCP would add connection setup noise (lab 28 already covered asyncio vs threads on localhost echo).
Ideal concurrent wall is one dt. At 40×10 ms, gather/TaskGroup sat ~10.8–11.0 ms — about 1.1× ideal — the gap is task creation + loop scheduling. Sequential paid the full N×dt (~415 ms). If your “async” code still looks like sequential awaits, you will never see that ~38× cliff.
for loop — the classic “async but serial” bug.Evidence: summary.json, summary.txt.
One Linux box. Sleep-only I/O proxy. No uvloop. Python 3.11+ TaskGroup. Numbers move with timer resolution and load.
For overlapping waits, asyncio.gather and TaskGroup both crush sequential awaits (~38.15× at 40×10 ms; ~83.63× at 100×5 ms) and stay near the ideal one-delay wall. Choose TaskGroup for structured failure/cancel; gather for list results / return_exceptions. Stop writing sequential await loops for independent I/O.
If a task fails, TaskGroup cancels its sibling tasks and raises an ExceptionGroup when the async with block exits. That is structured concurrency: no task outlives the group.
No. They were tied: 38.15x vs 37.87x over sequential at 40 x 10 ms, and 79.3x vs 83.63x at 100 x 5 ms. return_exceptions=True did not change throughput either.
When you want a results list or return_exceptions=True to collect failures without cancelling the rest. TaskGroup needs Python 3.11+.
Lab evidence
Lab 1 Oct 2026 IST. Ran Python 3.13.5 on Linux localhost using asyncio.sleep as an overlapping I/O proxy. At 40 x sleep(10 ms), sequential was 0.415 s, gather 0.0109 s (38.15x), and TaskGroup 0.011 s (37.87x). At 100 x sleep(5 ms), gather was 79.3x and TaskGroup 83.63x. Gather and TaskGroup were effectively tied on happy-path throughput; no ThreadPool arms, Docker, or affiliate links were used.
Explore the product
Untested by us. Check what's included, the license and refund terms before you buy.
Hands-on itertools.islice vs list slice window lab: real ops/s taking ranges from sequences, measured on Linux localhost in this hands-on lab for SREs.
Lab1 Oct 20264 min read
Hands-on heapq.merge vs sorted(chain) multi-way merge: real records/s on pre-sorted lists, measured on Linux localhost today in this hands-on lab for SREs.
Lab1 Oct 20264 min read
Hands-on mmap MAP_SHARED write+msync vs pwrite region update: real MB/s with durability labels, measured on Linux localhost in this hands-on lab for SREs.
Lab1 Oct 20264 min read