asyncio without accidental overload

Use TaskGroup, gather, queues, and semaphores with clear failure boundaries

`TaskGroup` (PEP 654, Python 3.11) provides structured concurrency: if one task raises an unhandled exception, all sibling tasks are cancelled, and an `ExceptionGroup` is raised once all tasks complete. This is fundamentally different from `asyncio.gather()`, which collects results or individual exceptions but does not cancel siblings on failure. `TaskGroup` uses an `async with` block as the failure boundary. Tasks created within the group are owned by it. On exit, the group awaits all tasks. On exception, it cancels remaining tasks. For concurrency limits, pair `TaskGroup` with `asyncio.Semaphore`. For blocking work, use `asyncio.to_thread()`. <a href="/async-context-backpressure">Learn backpressure and semaphore patterns</a>. <a href="/async-foundations-awaitables">Review awaitable basics first</a>. <a href="/runtime-gil-performance">Understand threading vs async tradeoffs</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Tasks need an owner or they become cleanup debt.

:)
TABLE OF CONTENTS
6.7asyncio without accidental overload

Use TaskGroup, gather, queues, and semaphores with clear failure boundaries

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.

Core answer

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 asyncio
async 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()
Mechanism and scheduling

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 asyncio
async 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 failure semantics

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 ExceptionGroup or BaseExceptionGroup
  • KeyboardInterrupt and SystemExit are 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 asyncio
async 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()
Bound fan-out and blocking work

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 asyncio
async 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 asyncio
import json
from pathlib import Path
async def load_json(path):
text = await asyncio.to_thread(Path(path).read_text, encoding="utf-8")
return json.loads(text)
Version context

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.

Edge cases and gotchas

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 aggregation
  • TaskGroup is 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.

Production usage

Use ──────────────────────────────────────────────

  • TaskGroup for "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.

Further depth
  • asyncio
  • Coroutines and tasks
  • asyncio.TaskGroup
  • asyncio.to_thread
  • Language reference: coroutine function definition
BOARD NOTESContext
WHY NO BENCHMARK?

This topic is better taught with structure, semantics, and cross-references than with a synthetic chart.

Tasks need an owner or they become cleanup debt.

RELATED GUIDES
NEXT CHECKS
Contribute