An asynchronous function is easy to call and surprisingly easy to abandon. Start a task, keep no handle, and the caller can return while the task still holds a socket, mutates state, or reports an exception to nobody. The control flow shown by the source no longer describes the lifetime of the work. Shutdown becomes a search for invisible activity, and a failed child can leave siblings running against assumptions that are already false.
Structured concurrency restores a familiar property from ordinary block structure: work begun in a scope is finished before that scope exits. Concurrent tasks form a tree. A parent owns its children, waits for them, propagates cancellation downward, and receives failures upward. Cleanup remains inside the lifetime of the operation rather than becoming a best-effort callback after the caller has moved on.
The thesis is not that every task must be short-lived or that all failures are fatal. It is that every task needs an explicit lifetime owner, and scope exit must settle the work that owner accepted. Some scopes fail as a unit; some deliberately supervise independent children; application scopes can last for the whole process. Structure makes each policy visible and testable.
Give Every Task a Lifetime Owner
Consider a request handler that starts an audit write with a bare task-spawning call and immediately returns a response. Who waits for the write? If the client disconnects, should it continue? If deployment shutdown begins, how long may it run? Where does its exception go? The line that creates the task answers none of those questions.
Structured concurrency imposes an ownership invariant:
A task may not outlive the scope that created it unless ownership is explicitly transferred to a longer-lived scope.
The ordinary case is lexical. Enter a task group, start children, and leave the group only after every child has completed, failed, or acknowledged cancellation. Scope exit acts as a join barrier. The parent cannot accidentally discard handles because the group retains them and defines what happens when one fails.
This is analogous to resource management. A file opened in a block is closed before the block exits. A lock acquired by a block is released on every exit path. A structured task is a live resource: it consumes scheduler attention, retains captured objects, may hold external resources, and can produce effects. Its lifetime deserves the same visible boundary.
Explicit transfer is sometimes correct. A request can validate an email command and enqueue it to a durable application-owned worker. The request scope then owns enqueueing, not email delivery. The queue owns accepted work and provides persistence, retry policy, and shutdown behavior. By contrast, merely placing a task in a global set prevents garbage collection but does not establish a meaningful operational owner.
The invariant gives code review a concrete question: for every spawn, which scope joins it? If the answer is “the runtime eventually will,” the program has unstructured work.
Read Concurrent Control Flow as a Task Tree
A task tree records dynamic parent-child relationships. The root might be the process lifecycle. Its children include an HTTP server, a queue consumer, and a metrics publisher. Each incoming request creates a smaller scope; that scope can start independent database and service calls. The tree does not require one operating-system thread per task. It describes ownership, not scheduling.
The shape provides containment. Cancelling the request scope reaches its three descendants but not the queue consumer. Stopping the application reaches all top-level services and, through them, their active children. A request cannot return while one of its owned calls is silently running, because the request scope cannot close first.
Tree structure also improves observability. A trace span can correspond to a scope, with child spans for child tasks. Active-task counts can be attributed to owners such as request, queue-item, or application-service. During shutdown, operators can ask which top-level scope still has descendants instead of listing unrelated tasks by runtime ID.
Lexical nesting is the ideal, but dynamic systems still fit. A connection handler may create a per-connection scope whose duration is determined by the protocol. A server scope owns all connection scopes and closes them before the listener exits. The boundary is dynamic in time yet explicit in ownership.
Not every dependency should become a child. If a request sends work to an already running database pool, the pool’s worker tasks belong to the pool’s application scope; the individual operation submitted to it belongs to the request until completed or cancelled. Separating service lifetime from operation lifetime prevents a cancelled request from destroying shared infrastructure.
Define Failure and Cancellation Propagation
Concurrency creates multiple possible outcomes at once. A task group needs a rule for combining them. A common fail-fast policy is: when one child fails, cancel unfinished siblings, wait for their cleanup, then raise the failure from the group. This policy fits children that jointly produce one result. Continuing only part of the operation would waste work or return an incoherent answer.
Cancellation is a request for early termination, not an interrupt that instantly makes a task disappear. Cooperative runtimes deliver it at suspension or cancellation-check points. Code doing a long CPU loop without checking cannot respond promptly. Code blocked in an API that ignores cancellation may hold the entire parent scope open. Libraries therefore need to document cancellation behavior alongside success and error behavior.
Direction matters. A parent cancellation normally flows down to descendants. An ordinary child failure flows up to its owning scope, which applies policy. Sibling cancellation is a consequence of a fail-fast scope, not a magical direct signal between siblings. This keeps the owner responsible for deciding whether their work is coupled.
Several children can fail before cancellation takes effect. A runtime should retain all meaningful failures, often in an aggregate exception, rather than choosing one by timing and discarding the rest. One error may be the initiating fault while another comes from cleanup. Logs and tests should preserve both without reporting expected cancellation as an independent incident for every sibling.
Cancellation should not be swallowed. A broad except or promise rejection handler that converts cancellation into success tells the parent that shutdown completed while the child may continue. Catch cancellation only to restore invariants or translate it at a deliberate API boundary, then re-raise or return the runtime’s recognized cancellation outcome.
Structured concurrency organizes cancellation propagation; it does not choose end-to-end timeout values. A caller may attach a cancellation source for many reasons, including client departure or shutdown. Whatever the source, descendants should observe one coherent lifetime decision.
Make Cleanup Part of Completion
A task is not complete when its main calculation stops; it is complete when resources and externally visible state satisfy the function’s exit contract. finally blocks, context managers, and deferred cleanup belong inside the child lifetime. The parent waits for them before treating the group as settled.
Cleanup should generally reverse acquisition order. Stop producing new work, release per-operation resources, flush or roll back local state as required, then release shared leases. An async resource must support async cleanup because closing a stream or transaction may itself suspend. Cleanup routines should be idempotent where possible so a partially completed close can be retried safely by its owner.
Cancellation makes cleanup design visible. If a task is cancelled while holding a database transaction, its finally block must roll back before the connection returns to the pool. If rollback is itself interrupted, the pool must discard or quarantine the uncertain connection rather than hand it to another request. A narrow cleanup operation may need temporary cancellation shielding, but shielding the whole task defeats prompt shutdown. Bound the protected region and keep it free of unrelated work.
External effects require their own contracts. Cancelling after an HTTP request was sent does not prove the remote operation did not happen. Structured lifetime prevents the local caller from forgetting the task; it cannot roll back the network. Idempotency keys, transactions, or reconciliation remain necessary where ambiguous completion matters.
Cleanup failures should not erase the original fault. Preserve an initiating read failure and a subsequent close failure as an ordered or aggregated diagnostic. Conversely, do not emit a page for every expected CancelledError during normal shutdown. The owner should summarize the scope outcome with enough child context to debug abnormal cleanup.
Worked Example: Build One Account Snapshot
Suppose an endpoint returns an account snapshot assembled from profile, orders, and access-policy services. The response is valid only if all three values describe the same request attempt. The calls are independent enough to overlap, but if one fails, the others have no result to contribute. This is a fail-fast task group.
Python’s asyncio.TaskGroup makes the ownership rule concrete. On normal exit it waits for every child. When a child raises a non-cancellation exception, it cancels unfinished siblings, waits for them, and raises an exception group.
import asyncio
from dataclasses import dataclass
from typing import Any
class UpstreamError(Exception):
pass
class SnapshotUnavailable(Exception):
pass
@dataclass(frozen=True)
class AccountSnapshot:
profile: dict[str, Any]
orders: list[dict[str, Any]]
access: set[str]
async def read_json(response: Any) -> Any:
try:
if response.status >= 500:
raise UpstreamError(f"upstream returned {response.status}")
return await response.json()
finally:
await response.aclose()
async def load(client: Any, path: str) -> Any:
response = await client.get(path)
return await read_json(response)
async def build_snapshot(client: Any, account_id: str) -> AccountSnapshot:
try:
async with asyncio.TaskGroup() as group:
profile = group.create_task(
load(client, f"/profiles/{account_id}"), name="profile"
)
orders = group.create_task(
load(client, f"/orders/{account_id}"), name="orders"
)
access = group.create_task(
load(client, f"/access/{account_id}"), name="access"
)
except* UpstreamError as failures:
raise SnapshotUnavailable(account_id) from failures.exceptions[0]
return AccountSnapshot(
profile=profile.result(),
orders=orders.result(),
access=set(access.result()),
)
The task variables become usable only after the group exits successfully. There is no branch in which build_snapshot returns while orders remains active. Every response is closed in finally, including when a sibling’s failure cancels the read. If the orders service returns a server error, the group requests cancellation of profile and access, waits for both cleanup paths, then translates matching upstream failures at the endpoint boundary.
The example deliberately does not detach an audit task. If snapshot attempts must be audited, the handler can await an audit write in its scope or submit a record to a durable owner before returning. Which choice is correct depends on the product contract, but the ownership transition must be explicit.
There is also a policy edge: except* UpstreamError handles only matching failures. A programming error such as KeyError remains unhandled and propagates rather than being mislabeled as an unavailable service. Aggregated failure preserves the distinction even when two children fail close together.
Supervise Independent and Long-Lived Work
Fail-fast is not universal. An application may run a metrics publisher and a cache refresher whose failures should not stop the HTTP server. A supervisor scope still owns those tasks, but its policy can record a failure, apply a bounded restart rule, or disable one service while others continue. This is structured supervision because children remain registered, observed, and joined at application shutdown.
Supervision must define a terminal condition. Restarting forever with no delay, cap, or health signal turns a persistent bug into a hot loop. Classify failures, reset retry state after sustained health, and stop restarting when the process cannot provide its promised service. The supervisor should expose whether a child is running, failed, restarting, or intentionally disabled.
Long-lived work should attach to the narrowest owner with the same lifetime. A listener belongs to the server scope. A per-connection reader belongs to the connection scope. A periodic refresh shared by all requests belongs to the application service, never to whichever request happened to initialize it first. On shutdown, stop accepting new children, signal existing descendants, and wait for their cleanup before releasing shared dependencies.
A durable queue is often the right owner for work that must survive process death. In-memory supervision can preserve a task across request completion but cannot preserve it across a crash. Structure clarifies that boundary instead of turning a detached task into accidental durability.
Recognize Tradeoffs and Unstructured Failure Modes
Structured scopes can delay return because they wait for cleanup. That is a feature when the function claims its work is done, but it exposes libraries with slow or non-cancellable close paths. Repair the resource contract or transfer ownership deliberately; do not detach cleanup to make latency graphs look better.
Strict fail-fast behavior can waste a completed sibling result when another child fails. If partial results are meaningful, represent them explicitly with per-child outcomes or use a supervised scope. Do not simulate partial success by catching every exception inside children and returning None; that erases whether absence, cancellation, and failure mean different things.
Common unstructured failures include:
- Starting tasks in constructors or module initialization with no lifecycle handle.
- Returning from a callback while spawned descendants still reference request state.
- Catching cancellation in a retry loop and immediately starting another attempt.
- Using a global task registry without shutdown, failure, or capacity policy.
- Holding a lock while a task group waits for children that need the same lock.
- Shielding broad regions so parent cancellation cannot make progress.
- Treating a spawned network effect as completed because its local task was cancelled.
Task trees also have overhead: groups retain bookkeeping, failure aggregation adds complexity, and code must expose scopes through APIs. The alternative cost is implicit lifetime state spread across logs and global registries. For server and UI workflows with cancellation, the explicit structure usually pays for itself; tiny leaf calculations may remain simple sequential awaits.
Verify Lifetimes and Observe Shutdown
Test structure with controllable suspension points rather than wall-clock sleeps. A fake dependency can signal when a child starts, wait on an event, and record when its finally block completes. Fail a sibling, then assert that cancellation reaches the blocked child, cleanup finishes, and the parent does not return beforehand. Cancel the parent and make the same assertions for every descendant.
Cover simultaneous failures by releasing two failing children together and checking that diagnostic aggregation retains both relevant errors. Inject a cleanup failure and ensure it does not hide the initiating fault. For supervised scopes, use a fake restart policy and assert limits, state transitions, and final joining. At test teardown, fail if unexpected tasks remain active; this catches ownership leaks close to their source.
Lifecycle tests should include shutdown while idle, during child startup, during ordinary work, and during cleanup. Verify that the system first stops accepting new work, then cancels or drains according to policy, and finally releases shared pools. A forced-stop test confirms the process has an outer bound, but graceful behavior should be asserted through events and state rather than timing guesses.
Operationally, record active children by stable scope type, scope age, cancellation reason, cleanup duration, restart state, and unhandled failure count. Trace parent-child links and mark cancellation as a control-flow outcome distinct from an error. Avoid task IDs as metric labels; use traces or bounded diagnostic snapshots for individual instances.
During an incident, the task tree should answer three questions: which owner accepted this work, why is its scope still open, and which descendant has not settled? If the runtime cannot expose those relationships directly, wrap task-group creation with naming and tracing conventions that do.
Structured concurrency makes concurrent work obey the same principle as structured resources: acquisition creates an obligation, and leaving the scope discharges it. Task trees do not remove cancellation races, remote ambiguity, or the need for supervision. They place those concerns under an owner whose return, failure, and shutdown behavior can be reasoned about, tested, and observed.