skip to content

What does `async with asyncio.TaskGroup()` guarantee about the tasks created inside the block?

level: juniorimportance: should knowfreq 42%

answer

  1. Concurrency with a visible open and close
  2. Nothing outlives the block that started it
  3. The closing edge does the waiting
  4. tg.create_task, then read results after

basics

~10 s

asyncio.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 lines
python
import 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context