You fire off several asyncio tasks with gather and hope nothing fails. But when one task raises, the others keep running — or get orphaned. TaskGroup solves that with structured concurrency: tasks that belong together fail together.
Think of a TaskGroup like a shipping container manifest. Every item in the container is listed on the same sheet. If one item is damaged, the whole container is flagged for inspection. That is structured concurrency — parent and child tasks share a fate.
await is the scheduling boundary. A coroutine that does long CPU work without awaiting blocks the entire event loop thread.
# [OLDER / 3.7-3.8, CURRENT - 3.10-3.14] Works on Python 3.7+async def fetch(client, url): response = await client.get(url) return response.status_code, await response.text()Use TaskGroup in Python 3.11+ when several child tasks belong to one parent operation and should succeed or fail as a unit.
# [CURRENT - 3.11-3.14] Requires Python 3.11+import asyncioasync def load_user_page(user_id): async with asyncio.TaskGroup() as tg: user_task = tg.create_task(fetch_user(user_id)) orders_task = tg.create_task(fetch_orders(user_id)) return user_task.result(), orders_task.result()The event loop runs ready callbacks and tasks. When a coroutine awaits an incomplete awaitable, its task is suspended and the loop can run another ready task. This is cooperative scheduling: progress depends on hitting await points.
# [OLDER / 3.7-3.8, CURRENT - 3.10-3.14] Works on Python 3.7+import asyncioasync def worker(name, delay): print("start", name) await asyncio.sleep(delay) print("done", name)async def main(): await asyncio.gather(worker("a", 0.2), worker("b", 0.1))asyncio.run(main())CPython details such as selector/proactor implementations and internal task objects are implementation-specific. The durable rules are the semantics of coroutines, tasks, cancellation, and the documented asyncio APIs.
TaskGroup provides structured failure handling. The official docs specify:
- if one child task fails with a non-cancellation exception, the remaining tasks are cancelled
- once the group exits, those failures are raised as an
ExceptionGrouporBaseExceptionGroup KeyboardInterruptandSystemExitare treated specially and re-raised
That behavior is much safer than "fire off tasks and hope you remembered to await them all."
# [CURRENT - 3.11-3.14] Requires Python 3.11+import asyncioasync def handle_request(user_id): try: async with asyncio.TaskGroup() as tg: profile = tg.create_task(load_profile(user_id)) tg.create_task(write_audit_event(user_id)) except* OSError as group: raise RuntimeError("request dependencies failed") from group return profile.result()Unbounded concurrency can overload your process, upstream service, connection pool, or file descriptor limits. Bound fan-out explicitly.
# [OLDER / 3.7-3.8, CURRENT - 3.10-3.14] Works on Python 3.7+import asyncioasync def fetch_all(urls, client, limit=20): semaphore = asyncio.Semaphore(limit) async def one(url): async with semaphore: return await client.get(url) return await asyncio.gather(*(one(url) for url in urls))Blocking file I/O, compression, JSON parsing of huge payloads, and synchronous clients still block the event loop if called directly. asyncio.to_thread() can move blocking call sites to a worker thread. For blocking I/O and C extensions that release the GIL, this provides near-full parallelism. For pure Python CPU work, the thread still contends for the GIL, but the event loop regains control during periodic GIL releases (every ~5ms), preventing complete event-loop starvation.
# [OLDER / 3.9, CURRENT - 3.10-3.14] Works on Python 3.9+import asyncioimport jsonfrom pathlib import Pathasync def load_json(path): text = await asyncio.to_thread(Path(path).read_text, encoding="utf-8") return json.loads(text)asyncio.run() is Python 3.7+. asyncio.to_thread() is Python 3.9+. asyncio.TaskGroup is Python 3.11+. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.
ExceptionGroup and except* handling are part of Python 3.11+, which is why structured task failure handling becomes substantially cleaner there.
Cancellation is part of normal control flow in asyncio. Swallowing CancelledError casually can break shutdown and timeout behavior.
asyncio.gather() and TaskGroup are not equivalent:
gather()is result aggregationTaskGroupis lifecycle and failure-structure management
Use the one whose semantics match the operation.
Async is not a shortcut around CPU limits. For CPU-bound Python workloads, measure first and then consider multiprocessing or native code that releases the GIL.
Use ──────────────────────────────────────────────
TaskGroupfor "these subtasks belong to one request"gather()for "I need these results collected"- semaphores or queues to cap concurrency
to_thread()only at well-defined blocking boundaries
Pair async code with safe structured logging from and explicit backpressure design. Most production asyncio failures are not syntax problems; they are overload, cancellation, timeout, and shutdown-discipline problems.