What does `async with asyncio.TaskGroup()` guarantee about the tasks created inside the block?
answer
- Concurrency with a visible open and close
- Nothing outlives the block that started it
- The closing edge does the waiting
- tg.create_task, then read results after
basics
~10 sasyncio.TaskGroup scopes its children to the block: the async with statement cannot finish until every task started with tg.create_task has finished, so no child task outlives the code that spawned it.
solid answer
~40 s`asyncio.TaskGroup` is an async context manager. Inside `async with asyncio.TaskGroup() as tg:` you spawn children with `tg.create_task(coro())`, which returns an `asyncio.Task` and schedules it on the running event loop. The guarantee is at the closing edge: `__aexit__` waits for every task the group owns before the block completes, so a function that opens a group leaves nothing running when it returns. The group also holds strong references to its tasks, so no child can be garbage-collected mid-flight. It returns no results — keep the `asyncio.Task` objects and read `Task.result()` after the block, where every task is guaranteed settled. That lexical boundary around concurrency is what "structured concurrency" means, and it is why the pattern is nicknamed a nursery.
code
python · 13 linesimport asyncio
async def convert(page: int) -> str:
await asyncio.sleep(0.01 * page)
return f"page-{page}"
async def main() -> None:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(convert(p)) for p in range(3)]
print("block body finished, children still running")
print([t.result() for t in tasks])
asyncio.run(main())go deeper
Recall the shape: async with asyncio.TaskGroup() as tg: plus tg.create_task(...), and the one promise that the block does not exit until every child has finished. Be able to say where you read results from.
Explain the mechanics: __aexit__ is what waits, the group holds strong references to its tasks, and Task.result() is only safe after the block because before it the task may still be pending.
Show why this matters in a service: a function that fans out inside a group leaves no orphan work when it returns, so callers never inherit tasks that keep using connections after the request they belonged to is gone.
Own the convention: decide whether the codebase spawns only inside groups, how long-lived supervisors are structured as long-lived blocks, and what the migration path is for older hand-rolled create-task-then-gather code.
### The block is the boundary `asyncio.TaskGroup` is an **asynchronous context manager**: you always use it as `async with asyncio.TaskGroup() as tg:`. Entering the block gives you the group object; every child you start goes through `tg.create_task(coro())`, which schedules the coroutine on the running event loop and returns an `asyncio.Task` exactly as the module-level task factory would. The one thing the group adds is a promise about the closing brace: **the `async with` statement will not finish until every task it owns has finished** — completed, failed, or been cancelled. Control cannot fall through the bottom of that block while a child is still running. That promise is what "structured concurrency" means in practice, and it is why the group is sometimes called a *nursery*: concurrency gets a lexical shape, the same way a `with` block gives a file handle a lexical shape. A function that opens a group and returns has, by construction, left nothing running behind it. Its caller does not need to know that the function used concurrency at all. ### What actually happens at the two edges `__aenter__` does almost nothing — it records the running loop and hands you the group. All of the interesting work is in `__aexit__`, which runs when the body ends for any reason: 1. If the body raised, that exception is treated like a child failure (see the error question on this topic). 2. The group then waits for its outstanding children. 3. Only after the last child has settled does the `async with` statement complete. Because the group holds a strong reference to each task for its whole lifetime, the classic fire-and-forget hazard — a bare task object with no owner, eligible for collection mid-flight — cannot happen to a task created through `tg.create_task`. ### Reading results `TaskGroup` deliberately does **not** return a list of results; it is a supervisor, not a result-collector. If you want the values, keep the `asyncio.Task` objects `create_task` handed you and read `Task.result()` *after* the block: ```python async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(convert(p)) for p in pages] print([t.result() for t in tasks]) ``` Reading `.result()` **inside** the block is a mistake beginners make: at that point the task may not have finished, and `Task.result()` raises `InvalidStateError` rather than waiting. Outside the block, every task is guaranteed settled, so the calls are safe. ### Lifetime of the group object A `TaskGroup` is single-use and cannot be kept around as a general task registry. Once the block has exited, calling `tg.create_task(...)` raises `RuntimeError` ("... is finished"). Nor can you spawn into a group from outside its block — the group only exists, usefully, for the duration of the `async with`. A long-lived supervisor is a group whose block itself is long-lived: you open it in a service's main coroutine and let it stay open for the process's life, spawning into it from within. ### Nesting Groups nest naturally, and that is the normal way to build a tree of work: a child task can open its own `async with asyncio.TaskGroup()` and become a supervisor for grandchildren. The failure and cancellation rules then compose — an inner group finishes its own children before its own task is allowed to finish, so the outer group's guarantee still holds for everything underneath. ### What it replaces Before `TaskGroup` (Python 3.11), the equivalent was a hand-rolled pattern: create tasks, keep the list, `await asyncio.gather(*tasks)`, and add a `try`/`finally` that cancels the leftovers on the error path. That pattern is easy to write and easy to write *slightly* wrong: forget the `finally` and a raised exception leaves live tasks behind that will surface much later as "Task exception was never retrieved" on an unrelated line, or as work that keeps hitting a database after the request it belonged to was abandoned. `TaskGroup` makes the correct version the short version, which is the real argument for it in a code review. The cost is that the group is opinionated: it is all-or-nothing by default, so a workload where a few failures should not stop the rest needs each child to handle its own errors, or a different tool entirely. That trade is the subject of the comparison question on this topic.
- How do you get the return values out of a task group?Keep the `asyncio.Task` objects that `tg.create_task` returns and call `Task.result()` after the `async with` block has exited. The group itself returns nothing. Reading `.result()` inside the block is a bug: the task may still be pending and `Task.result()` raises `InvalidStateError` instead of waiting. Once the block has exited, every task it owned is settled, so the calls are safe.
- Can you keep a TaskGroup object around and spawn into it later?No. A group is usable only for the duration of its `async with` block; once the block has exited, `tg.create_task(...)` raises `RuntimeError` saying the group is finished. If you need a long-lived supervisor, keep the *block* long-lived — open it in the service's main coroutine and spawn from inside — rather than passing the group object outward.
- Can task groups be nested?Yes, and that is the intended way to build a tree of work. A child task may open its own `async with asyncio.TaskGroup()` and supervise grandchildren; the inner block finishes its own children before that task is allowed to finish, so the outer group's "nothing outlives me" guarantee still covers everything underneath.
A with block guarantees the file is closed on the way out; a task group guarantees the workers are finished on the way out. Concurrency gets the same visible open-and-close shape as a resource.
saying these in an interview costs you the question
- Says the block exits as soon as its last statement runs
- Calls Task.result() inside the block instead of after it
- Thinks TaskGroup returns a list of results like a fan-out helper
- Stores the group object and spawns into it after the block
- Believes tasks still need a separate strong reference set