5 min read
If you are still writing asyncio.gather(*tasks, return_exceptions=False) in 2026, you are shipping bugs. Stop using asyncio the old way: TaskGroups, added in Python 3.11 and tightened up in 3.12, are the only sane way to handle Python concurrency in production code. They give you structured concurrency, which means if one task in a group raises, the whole group cancels cleanly, exceptions are bundled into an ExceptionGroup, and you cannot leak half-finished coroutines into the event loop. That is the entire pitch in one paragraph. The rest of this post is the proof.
The classic asyncio.gather pattern looks friendly. You spawn a dozen coroutines, you await the gather, you ship it. Then production hits and you discover three problems that every senior engineer at Monzo or the BBC has had to patch at 2am.
First, error handling is a lie. The default return_exceptions=False means the first exception cancels the gather and the others keep running silently until they finish or until the event loop closes. You have no idea what state your downstream HTTP clients, database pools, or S3 uploads are in. Second, cancellation is fire-and-forget. If you wrap gather in a try/except and swallow the exception, the child tasks are not actually cancelled in a deterministic order; they are just orphaned with a CancelledError injected. Third, there is no structured scope. You cannot tell, reading the code six months later, where the "boundary" of your concurrent work is. TaskGroups fix all three.
If you want the receipts, the "Structured Concurrency" PEPs (PEP 654, 668, and the trio docs that influenced asyncio) spell it out: concurrent tasks should have a single lifetime, a single parent, and a single failure mode. gather violates all three.
Here is the shape I ship in every FastAPI service at the HMRC integration tier I work on. It replaces a 40-line gather block with 15 lines you can actually reason about.
import asyncio
import httpx
async def fetch_price(client: httpx.AsyncClient, sku: str) -> float:
r = await client.get(f"/prices/{sku}", timeout=2.0)
r.raise_for_status()
return r.json()["gbp"]
async def price_basket(skus: list[str]) -> list[float]:
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch_price(client, s)) for s in skus]
# If we get here, every task either succeeded
# or ExceptionGroup was raised by the context manager
return [t.result() for t in tasks]
Notice what is missing: no try/except ladder, no manual cancellation, no return_exceptions=True flag, no asyncio.wait gymnastics. When the async with asyncio.TaskGroup() block exits, every task inside it has either completed or been cancelled. The context manager guarantees that. If two tasks raise, you get an ExceptionGroup containing both, which you can handle with except* in Python 3.11+.
Teams that learned Python before 3.11 tend to flinch at ExceptionGroup. Do not. It is the first time the language lets you cleanly express "three things failed, here are all three." For an HTTP fan-out against 20 microservices, that is exactly what you want logged. Wrap the group with except* httpx.HTTPError to peel off the network failures and except* ValueError for the JSON parse failures, then re-raise anything else.
Structured concurrency means the lifetime of a task is bounded by its parent scope. If the parent is cancelled, or if a sibling raises, every task in the group receives a CancelledError and is awaited before the context manager exits. No zombies, no half-closed connections, no "works on my machine" deployments.
Compare this to the gather pattern that NHS Digital teams have blogged about debugging for years:
return_exceptions=True hides failures behind a list of Exception objects you have to iterate manually.return_exceptions=False cancels the group but does not wait for the cancellations to complete before raising.finally block to clean up the AsyncClient or the database pool, and you will forget it at least once.TaskGroups collapse all of that into the async with statement. The compiler, the runtime, and your future self all know exactly when the work is done.
I am not a zealot. There are two cases where raw asyncio.create_task without a TaskGroup is fine. Long-running background workers that should outlive the current request, like a GOV.UK notification dispatcher, belong in asyncio.create_task stored on an app-state object with an explicit shutdown handler. Fire-and-forget telemetry pings also belong outside a TaskGroup, precisely because you do not want a slow StatsD client to cancel your main request.
For everything else, and especially anything that fans out to multiple I/O sources, fetches data for a single user-facing response, or aggregates results from more than two coroutines, the answer is TaskGroup. The structured-concurrency model is not a stylistic preference; it is the only concurrency primitive in the standard library that gives you correct cancellation semantics by default. Everything else is you hand-rolling what TaskGroup already does, badly, at 1am during an incident.
Python 3.11 shipped TaskGroups in October 2022. Python 3.12 improved cancellation propagation. Python 3.13 added better except* ergonomics. There is no excuse left for asyncio.gather in new code. If your team has a style guide that bans gather, congratulations, you already get it. If not, write the linter rule today and ship TaskGroups tomorrow. Structured concurrency is not a fancy new pattern; it is the same model that Kotlin coroutines and Swift structured tasks have shipped for years. Python finally caught up. Use it.
For 95% of production use cases, yes. gather still works and is fine for trivial scripts, but any code that handles real I/O, errors, or cancellation should use TaskGroup. Treat gather as legacy.
Python 3.11 introduced TaskGroups and ExceptionGroups. Python 3.12 improved cancellation handling and 3.13 refined except*. If you are stuck on 3.10, you can use the anyio library which shipped the same structured pattern years earlier.
Use the except* syntax introduced in 3.11. For example, except* httpx.HTTPError matches every HTTPError inside the group and removes them from the bundle, letting you re-raise the rest. This is the cleanest way to fan out exception handling across sibling tasks.