Skip to content

Structured Concurrency in Python: TaskGroups, Timeouts, and Cancellation

Modern backend services rarely perform just one operation per request. A handler may call several services, read from a cache, and write an audit event. The hard part is not merely starting work concurrently: it is ensuring that related tasks have a clear lifetime, failures are observed, timeouts are enforced, and cancellation reaches the work that should stop.

Python's asyncio.TaskGroup (Python 3.11+) provides structured concurrency for groups of related tasks. Combined with asyncio.timeout(), it gives request-scoped asynchronous work a predictable lifecycle.

1. The problem with unstructured task creation

Consider a request handler that creates background tasks with asyncio.create_task(). If the handler returns or raises before those tasks finish, the tasks may outlive the request. Their exceptions can also be easy to overlook.

Unstructured task creation is not inherently wrong. It is useful for deliberately independent, application-lifetime work when ownership and shutdown are handled explicitly. It is a poor default for child operations that should finish or fail with one request.

A useful invariant for request-scoped work is:

A parent operation should not finish until all child operations it owns have finished, failed, or been cancelled and cleaned up.

A TaskGroup is an asynchronous context manager. Tasks created through the group are awaited when the context exits. If a child task fails with a non-cancellation exception, the group cancels its remaining children, waits for them to settle, and then raises the failure (typically as an exception group).

import asyncio


async def fetch_profile(user_id: str) -> dict:
    await asyncio.sleep(0.05)  # Replace with an async database/client call.
    return {"user_id": user_id, "name": "Ada"}


async def fetch_permissions(user_id: str) -> list[str]:
    await asyncio.sleep(0.08)
    return ["read", "write"]


async def load_user_view(user_id: str) -> dict:
    async with asyncio.TaskGroup() as group:
        profile_task = group.create_task(fetch_profile(user_id))
        permissions_task = group.create_task(fetch_permissions(user_id))

    # Both tasks have completed successfully when execution reaches here.
    return {
        "profile": profile_task.result(),
        "permissions": permissions_task.result(),
    }


async def main() -> None:
    print(await load_user_view("u-123"))


if __name__ == "__main__":
    asyncio.run(main())

The operations overlap while waiting for I/O. The TaskGroup does not make CPU-bound Python code execute in parallel; it manages task ownership and failure propagation.

Why this is safer

  • The parent cannot accidentally leave these child tasks running after the group exits.
  • A failing child causes sibling work to be cancelled.
  • The group waits for cancellation cleanup before exiting.
  • The caller receives failures rather than silently losing task exceptions.

3. Timeouts: put a deadline around the operation

A timeout is a resource-control boundary. Without one, a slow dependency can occupy request capacity indefinitely.

Python 3.11 introduced asyncio.timeout(), which cancels the current task when the deadline expires and converts that cancellation into TimeoutError outside the context manager.

import asyncio


async def call_dependency() -> dict:
    await asyncio.sleep(2)
    return {"status": "ok"}


async def load_with_deadline() -> dict:
    try:
        async with asyncio.timeout(0.5):
            return await call_dependency()
    except TimeoutError:
        return {"status": "dependency_timeout"}


async def main() -> None:
    print(await load_with_deadline())


if __name__ == "__main__":
    asyncio.run(main())

Use a timeout that reflects the request's remaining budget, not an arbitrary value copied across every layer. If an incoming request has a 1-second deadline, downstream calls cannot each safely receive a fresh 1-second timeout.

Timeout versus wait_for

  • asyncio.timeout() scopes a deadline around a block of work.
  • asyncio.wait_for(awaitable, timeout=...) applies a timeout to one awaitable.
  • Both rely on cancellation; neither can safely stop arbitrary synchronous code that is blocking the event loop.

4. Cancellation is control flow, not an ordinary error

When a task is cancelled, asyncio injects asyncio.CancelledError at a suspension point. Coroutines should generally perform cleanup in finally and allow cancellation to propagate.

import asyncio


async def consume_messages() -> None:
    try:
        while True:
            message = await receive_message()
            await process_message(message)
    finally:
        await close_consumer()


async def receive_message() -> str:
    await asyncio.sleep(1)
    return "message"


async def process_message(message: str) -> None:
    await asyncio.sleep(0.1)


async def close_consumer() -> None:
    # Close network connections or release other owned resources.
    await asyncio.sleep(0)

Avoid swallowing CancelledError with a broad except BaseException or except asyncio.CancelledError: pass. Suppressing cancellation can break TaskGroup and timeout semantics. If cancellation is caught for cleanup or logging, re-raise it unless there is a deliberate, well-understood reason to suppress it.

Cleanup should be bounded too. A shutdown path that can wait forever defeats the purpose of cancellation.

5. Combining TaskGroup and a request deadline

A common backend pattern is to run independent dependency calls in one TaskGroup under a shared deadline.

import asyncio


async def get_account(account_id: str) -> dict:
    await asyncio.sleep(0.05)
    return {"id": account_id}


async def get_balance(account_id: str) -> int:
    await asyncio.sleep(0.07)
    return 1250


async def build_dashboard(account_id: str) -> dict:
    async with asyncio.timeout(0.3):
        async with asyncio.TaskGroup() as group:
            account_task = group.create_task(get_account(account_id))
            balance_task = group.create_task(get_balance(account_id))

    return {
        "account": account_task.result(),
        "balance_cents": balance_task.result(),
    }


async def main() -> None:
    try:
        print(await build_dashboard("acct-42"))
    except TimeoutError:
        print("dashboard deadline exceeded")


if __name__ == "__main__":
    asyncio.run(main())

The timeout covers the complete group, so both operations share one budget. If the deadline expires, cancellation is propagated into the child tasks and the TaskGroup waits for them to clean up.

In production, translate the timeout into an intentional API response and record which dependency or phase consumed the budget. Do not return partial data unless the endpoint contract explicitly permits it.

6. Exception groups and failure policy

A TaskGroup may report multiple child failures as an ExceptionGroup. This matters when more than one child fails before cancellation takes effect.

Use except* when you need to handle particular exception types inside an exception group:

try:
    async with asyncio.TaskGroup() as group:
        group.create_task(load_primary())
        group.create_task(load_secondary())
except* ConnectionError as errors:
    for error in errors.exceptions:
        log_connection_failure(error)

This example assumes load_primary, load_secondary, and log_connection_failure are defined by the application. Handle only failures for which the application has a clear recovery policy. Logging an exception does not automatically make the operation successful.

For many request handlers, the simplest correct policy is to let the group failure propagate to the service's normal exception handling layer.

7. Common production mistakes

  1. Creating detached tasks for request-owned work. Use a TaskGroup when child work belongs to the current operation.
  2. Giving every dependency a fresh full timeout. Propagate a shared deadline or remaining time budget.
  3. Swallowing cancellation. It can leave sibling tasks running and prevent timely shutdown.
  4. Blocking the event loop. A timeout cannot interrupt synchronous code that never yields.
  5. Assuming TaskGroup makes work faster. It improves lifecycle and failure management; speed depends on I/O overlap and workload.
  6. Returning before cleanup completes. Close streams, response bodies, transactions, and clients in bounded cleanup paths.
  7. Ignoring exception groups. Decide which failures are recoverable and which should fail the whole request.

8. Interview questions

Q1. What problem does structured concurrency solve?

Answer: It gives concurrent child tasks a bounded lifetime and clear ownership. The parent waits for its children, and failures/cancellation are propagated through the task group instead of leaving detached work behind.

Q2. What happens when one TaskGroup child raises?

Answer: The group cancels its remaining children, waits for them to finish cancellation and cleanup, and then raises the collected failure, commonly as an ExceptionGroup. A child cancelled on its own is treated differently from a child failing with an ordinary exception.

Q3. Does asyncio.timeout() kill a task?

Answer: No. It requests cancellation by cancelling the current task at an await point. The coroutine must cooperate with cancellation. Synchronous blocking code on the event-loop thread cannot be forcibly interrupted by this mechanism.

Q4. Why should CancelledError usually be re-raised?

Answer: Cancellation is how asyncio communicates that work should stop. Suppressing it can violate caller expectations, delay shutdown, and interfere with TaskGroup and timeout behavior.

Q5. When would create_task() still be appropriate?

Answer: When work is intentionally detached from the current lexical operation, such as an application-owned supervisor task. The application must then retain ownership: observe exceptions, define restart behavior, and cancel/await tasks during shutdown.

Q6. What is the difference between a per-call timeout and an end-to-end deadline?

Answer: A per-call timeout limits one operation. An end-to-end deadline bounds the entire request, including queueing, retries, and multiple downstream calls. Each layer should consume only the remaining budget.

9. Practical checklist

  • Use TaskGroup for related child tasks that should succeed or fail as one unit.
  • Set an end-to-end deadline and pass remaining budget to downstream operations.
  • Keep blocking work off the event-loop thread.
  • Let cancellation propagate after cleanup.
  • Bound concurrency and retry budgets independently of timeouts.
  • Record timeout, cancellation, and dependency-failure metrics separately.
  • Test partial failure, simultaneous failure, timeout, and cancellation cleanup.

References