asyncio After 3.11: TaskGroup Changed What Correct Code Looks Like

Three additions landed in 3.11 and quietly obsoleted most asyncio advice written before it. Plus the fire-and-forget footgun that silently drops your background task.
Author

Benedict Thekkel

Published

July 28, 2026

Most asyncio material online predates Python 3.11. That matters more than a version bump usually does, because TaskGroup, asyncio.timeout() and task.uncancel() did not add convenience. They changed which patterns are correct.

3.11 is the meaningful floor. Below it you are writing defensive gather code; above it you mostly are not.


The mental model, in three consequences

One OS thread runs an event loop holding a queue of ready callbacks. Coroutines run until they hit an await that actually suspends, then hand control back.

That gives you three facts which between them explain almost every asyncio bug:

  1. await is the only preemption point. Between two awaits your code is atomic; nothing else in the loop runs. This is a design tool, not just trivia. You often do not need a lock, because there is no interleaving unless you await inside the critical section.
  2. Blocking blocks everything. A time.sleep(1) or a CPU-bound loop freezes every task, timer and socket read in the process. There is no preemptive scheduler to rescue you.
  3. Concurrency is not parallelism. asyncio gives you thousands of concurrent IO operations on one core, and zero CPU parallelism.

Which yields the right question when reaching for it: am I waiting on things, or computing things? Waiting means asyncio. Computing means processes.


await does not create concurrency

The most common misunderstanding, and it is worth being blunt about:

coro = fetch(url)                   # NOT running. Just an object.
result = await coro                 # runs it inline - sequential
task = asyncio.create_task(coro)    # schedules it - concurrent

await a(); await b() is strictly sequential. Concurrency requires scheduling both first, then awaiting.

Type What it is
Coroutine The object returned by calling an async def. Inert until awaited or wrapped
Task A coroutine scheduled on the loop. Has a name, can be cancelled, tracks state
Future A low-level “result eventually” placeholder. Libraries create these, you rarely do

The footgun: fire-and-forget tasks vanish

The event loop holds only weak references to tasks. A task with no strong reference elsewhere can be garbage collected mid-execution, and it simply stops. No error, no warning.

# BROKEN - may vanish partway through
asyncio.create_task(background_work())

# Correct - keep a strong reference until it finishes
_background = set()

def spawn(coro):
    t = asyncio.create_task(coro)
    _background.add(t)
    t.add_done_callback(_background.discard)
    return t

This is the bug that produces “it works locally and drops requests in production”, because whether the collector runs at the wrong moment is a load-dependent accident.


What 3.11 changed

TaskGroup replaces most gather usage. The difference is failure semantics: if one child raises, the group cancels its siblings and propagates an ExceptionGroup. With gather you had to remember return_exceptions, then sort the results yourself, and cancellation of the others was your problem.

async with asyncio.TaskGroup() as tg:
    tg.create_task(fetch(a))
    tg.create_task(fetch(b))
# both done here, or the group raised and cancelled the rest

It also fixes the reference problem above: the group holds its tasks.

asyncio.timeout() is a context manager rather than a wrapper, so it composes with a block of work instead of a single awaitable.

async with asyncio.timeout(5):
    await step_one()
    await step_two()

task.uncancel() makes nested cancellation scopes work correctly, which is the machinery that lets the other two behave properly when timeouts and groups are nested.

flowchart LR
  subgraph Before[Pre-3.11]
    G[gather + return_exceptions] --> M[Manual result sorting]
    M --> C[Manual sibling cancellation]
    W[wait_for per awaitable] --> C
  end
  subgraph After[3.11+]
    TG[TaskGroup] --> A[Siblings cancelled,<br/>ExceptionGroup raised]
    TO[async with timeout] --> A
  end


Practical consequences

  • Default to TaskGroup. Reach for gather only when you genuinely want independent results and no sibling cancellation, and say so in a comment, because a reader will assume you meant TaskGroup.
  • Catch ExceptionGroup, not Exception. The except* syntax exists for this. Code that catches Exception around a task group will miss things.
  • Never let a blocking call in. run_in_executor or a process pool for CPU work; there is no partial credit here.
  • Treat pre-3.11 examples as archaeology. The patterns are not wrong so much as manual reimplementations of what the language now does properly.

Takeaway

If you learned asyncio before 3.11, the update is small to read and large in effect: TaskGroup for structure, async with asyncio.timeout() for deadlines, except* for the errors that come back. Most of the defensive scaffolding around gather can go.

And keep a strong reference to anything you fire and forget, because that one is still waiting for you regardless of version. Full reference, with the cancellation and synchronisation detail, is in Python Libraries.


Back to top