Python's Structured Concurrency — TaskGroup, Cancellation, the Timeout Scope
Where does a background task go when the function that started it returns? After 3.11, the right answer is: nowhere. The scope owns the task.
A timeout in asyncio is just scoped cancellation. The deadline and the group are the same primitive wearing two names.
§ IFrame
Where does a background task go when the function that started it returns?
Before Python 3.11 the honest answer was: nowhere good. asyncio.create_task launches a coroutine into the event loop and hands back a handle, and nothing in the language ties that task's lifetime to anything. Forget to await the handle and the task runs on as an orphan. It outlives its parent, holds its connections, and when it finally fails, the loop prints Task exception was never retrieved into a log nobody is watching, minutes after the code that could have handled it has returned. Ramalho, writing just before 3.11 landed, closes his asyncio chapter by pointing at Curio and Trio, the libraries that had already solved this with nurseries, scopes that own their tasks. asyncio.TaskGroup is that idea arriving in the standard library.
Structured concurrency is one rule: a task's lifetime is bounded by a lexical scope. Tasks are started inside a block, and the block does not end until every task in it has ended. Concurrency gets the property ordinary code has had all along, that when a function returns, its work is finished. The orphan is not discouraged. It is unrepresentable.
§ IILanguage Idiom
TaskGroup is an async context manager. Tasks are spawned with tg.create_task() inside the async with block, and the block's exit awaits them all. Three behaviors define it, and each is a rule worth holding separately.
The scope owns the task. Exit waits for every child, whether the block body ends normally or by exception. There is no path out of the block that leaves a task running. This is the context-manager guarantee from two weeks ago, exit-runs-regardless, extended to child tasks.
First failure cancels the siblings. When any child raises, the group cancels every other child, waits for the cancellations to finish, and then raises an ExceptionGroup carrying every failure that occurred. Friday's lesson taught except* as the syntax for handling grouped failures; TaskGroup is the machine that produces them. The posture is all-or-nothing: this scope's work is one unit, and a unit half-done is not worth finishing.
Cancellation is cooperative, and it has a contract. Cancelling a task does not kill it. It arranges for CancelledError to be raised at the task's next await. The task then runs its finally blocks, releases what it holds, and lets the exception continue. That propagation is the contract: machinery above you, a TaskGroup, a timeout, relies on seeing the CancelledError come back out to know the task honored the cancellation. Catch it to clean up if you must, but re-raise. A coroutine that swallows CancelledError is a task that cannot be stopped, and every timeout wrapped around it silently stops working.
asyncio.timeout() is the contract's main client. It is a context manager that cancels the work inside it when the clock expires, then converts the CancelledError back into TimeoutError at its own boundary. Read that mechanism twice: a timeout in asyncio is just scoped cancellation. The deadline machinery of yesterday's sweep and today's group are the same primitive wearing two names.
§ IIICode Worked Example
The specimen is a portfolio snapshot composed from three services: positions, prices, FX rates. A snapshot missing any leg is wrong, so all-or-nothing is the correct posture. First, the pre-3.11 orphan, the shape TaskGroup exists to abolish.
async def snapshot_badly(client):
positions = asyncio.create_task(fetch_positions(client))
prices = asyncio.create_task(fetch_prices(client))
rates = asyncio.create_task(fetch_rates(client))
return compose(await positions, await prices, await rates)
This looks fine and fails ugly. If fetch_positions raises, the function unwinds at the first await while prices and rates run on as orphans; their results are dropped, their failures surface later as unretrieved-exception noise. The structured version:
async def snapshot(client):
async with asyncio.TaskGroup() as tg:
positions = tg.create_task(fetch_positions(client))
prices = tg.create_task(fetch_prices(client))
rates = tg.create_task(fetch_rates(client))
return compose(positions.result(), prices.result(), rates.result())
The block exit is the join. If all three succeed, .result() is safe by the time the body resumes. If any fails, the others are cancelled, awaited, and the group raises one ExceptionGroup. Nothing outlives the scope, so the composing line can never see a half-made snapshot.
The caller stacks a deadline over the group and handles the grouped failures with Friday's syntax:
async def snapshot_with_deadline(client):
try:
async with asyncio.timeout(8.0):
return await snapshot(client)
except* (TimeoutError, TransportError) as grouped:
raise SnapshotUnavailable(len(grouped.exceptions)) from grouped
One deadline covers the whole unit, which is correct here because the unit is indivisible; per-task deadlines were yesterday's tool for tasks that are independent. except* partitions the group: expected transport failures become one domain-level exception, chained with from so the traceback keeps every original cause, while anything unexpected, a bug in compose, stays outside the handler and flies loud.
Last, the contract in the leaf coroutine, because everything above depends on it:
async def fetch_prices(client):
conn = await client.acquire()
try:
return await conn.get("/prices")
finally:
await conn.release()
When cancellation lands at the inner await, the finally releases the connection and the CancelledError continues out. That is the entire obligation. The one unforgivable version is except CancelledError: pass, which turns this leaf into a task no group can close and no timeout can end.
§ IVConnection to Today's Ops Lesson
The Ops sweep and this snapshot are opposite answers to the same question: what does one failure mean to the others? The sweep is a survey. Two hundred probes are independent, a timeout on one says nothing about the rest, so the sweep converts every failure into a ledger row before it can trigger anyone's cancellation and grades the batch at the end. The snapshot is a unit. Three legs mean one thing together and nothing apart, so the first failure should stop the spending immediately, and TaskGroup's cancel-the-siblings default is exactly right. Neither posture is the advanced one. The skill is naming which relationship your tasks actually have, then reaching for gather-over-rows or TaskGroup accordingly. Ramalho's all-or-nothing section names the older version of this same fork.
§ VPrior-Lesson Reach
Friday's Dev lesson taught ExceptionGroup and except* one cycle before this lesson introduced the construct that raises them; the sequencing was deliberate, and the pair should be read together. The context-manager lesson from the fourteenth supplies the deeper frame: TaskGroup, asyncio.timeout, and the semaphore in today's Ops lesson are all the same protocol, a scope whose exit logic cannot be skipped, applied to tasks, clocks, and permits respectively. And the K8s-day webhook from yesterday runs on this machinery without naming it: every FastAPI handler is a task in a server's scope, cancelled on client disconnect under exactly the contract §II states.
A grounding note worth recording: Fluent Python's second edition went to print months before 3.11 shipped TaskGroup, so it grounds this lesson's asyncio floor, awaitables, the event loop, the all-or-nothing problem, the semaphore, while the TaskGroup surface itself still has no tome behind it. The gap narrowed this week with the Ramalho acquisition; it closes fully only when a 3.11+ text lands.
§ VIClosing
Structured concurrency is one rule with three consequences. The scope owns the task, so nothing outlives its block. The first failure cancels the siblings, and the wreckage arrives as one ExceptionGroup at one place. Cancellation is a contract, CancelledError in at an await, cleanup in finally, the exception back out, and every timeout and every group is only as reliable as the leaf coroutines that honor it.
Search a codebase you own for create_task. For each call, find the line that awaits the handle and the code that would see its exception. Where you cannot find both, you have found an orphan, and the rewrite is a TaskGroup block.
Paired lessons → Ops 01-Earth-DevOps/Synthesis-Lessons/2026-07-27-concurrent-fleet-automation-... · Cert Cert-Prep/Google/2026-07-27-pmle-vertex-ai-pipelines-...
Filed 2026-07-27 Monday Fajr · Dev lesson · Python deep-mastery track (day 5, visit 2) · py-blue code borders
Backward-Synergy-Reach → exception model (07-24) + context-manager protocol (07-14) + FastAPI webhook (07-26)
Grounded in Fluent Python (Ramalho) Ch 21 pp 805-820 · HTML shipped in-cycle per HARD DISCIPLINE