Running queries in parallel with asyncio.TaskGroup

Open one AsyncSession inside each task created by an asyncio.TaskGroup, read results only after the async with block exits, and handle failures with except* — the group cancels the remaining queries for you when one fails. This is the structured-concurrency pattern from running concurrent queries with AsyncSession, worked through end to end.

Quick Answer

Python 3.11 added asyncio.TaskGroup, and for database fan-out it is strictly better than asyncio.gather(): when one task fails, the others are cancelled and their sessions closed before the error reaches you, so a failed request never leaves queries running behind it.

gather() and TaskGroup fail differently Left, gather: the first exception propagates to the caller immediately, but the other two tasks are not cancelled; they keep their sessions and connections open and keep querying after the request has already failed. Right, TaskGroup: when one task raises, the group cancels the remaining tasks, waits for them to finish unwinding so their sessions close and connections return to the pool, then raises an ExceptionGroup containing every error. asyncio.gather(a, b, c) task b raises IntegrityError caller sees the error at once a and c keep running their connections stay checked out unless you cancel them yourself async with TaskGroup() as tg task b raises IntegrityError the group cancels a and c waits for their sessions to close then raises ExceptionGroup nothing outlives the block Structured concurrency matters more for database work than for most I/O, because an orphaned task is holding a pooled connection that another request is waiting for.

Before — gather(), with failures that leak running queries:

import asyncio

from sqlalchemy import func, select

from shop.db import Session
from shop.models import Order, Refund


async def revenue(day) -> int:
    async with Session() as session:
        return await session.scalar(
            select(func.coalesce(func.sum(Order.total_cents), 0)).where(Order.placed_on == day)
        )


async def refunds(day) -> int:
    async with Session() as session:
        return await session.scalar(
            select(func.count(Refund.id)).where(Refund.created_on == day)
        )


async def daily_summary(day):
    # If refunds() raises, revenue() keeps running and holding a connection.
    total, refunded = await asyncio.gather(revenue(day), refunds(day))
    return {"revenue_cents": total, "refunds": refunded}

After — a TaskGroup, one session per task:

import asyncio
import datetime as dt

from sqlalchemy import func, select

from shop.db import Session
from shop.models import Order, Refund


async def daily_summary(day: dt.date) -> dict[str, int]:
    async with asyncio.TaskGroup() as tg:
        revenue_task = tg.create_task(revenue(day), name="revenue")
        count_task = tg.create_task(order_count(day), name="order_count")
        refund_task = tg.create_task(refunds(day), name="refunds")
    # The block has exited: every task finished and every session is closed.
    return {
        "revenue_cents": revenue_task.result(),
        "orders": count_task.result(),
        "refunds": refund_task.result(),
    }


async def order_count(day: dt.date) -> int:
    async with Session() as session:
        return await session.scalar(
            select(func.count(Order.id)).where(Order.placed_on == day)
        )

revenue() and refunds() are unchanged from the first example. Each helper owns its session from async with to return, which is the whole rule — the group supplies the concurrency, and the helpers never see each other's connections.

Execution Context & Async Workflow Integration

Inside the group, each create_task() schedules a coroutine that will run the next time the event loop is free. The first await in each helper checks a connection out of the pool; from then on the three queries are genuinely concurrent, each waiting on its own socket. The async with asyncio.TaskGroup() block does not exit until every task has finished, succeeded or failed, so the lines after it can read results without awaiting anything.

One request, three parallel reads Five steps. The request handler enters async with TaskGroup. It creates three tasks, each of which opens its own AsyncSession, runs one aggregate query and closes the session. The group block does not exit until all three have finished. After the block, the handler reads task.result for each. At no point does a session cross a task boundary. handler async with TaskGroup() as tg enters the group three create_task() calls revenue · order count · refunds each opens its own session three sessions, three connections queries run concurrently awaited together block exits all sessions closed connections back in the pool task.result() combine into the response Read results after the block, not inside it: inside, a sibling may still be running.

That waiting is what makes the pattern safe with pooled connections. A connection is returned to the pool when the session that checked it out closes, and each session closes when its helper's async with Session() block exits. Because the group waits for all helpers, all three connections are back in the pool by the time the handler continues. There is no window in which a request has returned a response while its queries are still running.

The cost is connections. A request that fans out to three tasks holds three connections for the duration of the slowest query, instead of one connection for the sum of all three. At low traffic that is free latency. At high traffic it triples each request's share of the pool, and the arithmetic in sizing the asyncpg pool for high concurrency has to be redone with tasks per request as a multiplier.

Separate sessions also mean separate transactions. Under PostgreSQL's default READ COMMITTED isolation, the revenue query and the order-count query each see the data as of their own start, so an order committed between them can appear in one and not the other. For a dashboard that is acceptable. For a report that must reconcile, either combine the reads into one statement or run them sequentially in one session at REPEATABLE READ.

Resolving Warnings, Errors & Common Mistakes

Most problems with TaskGroup fan-out come from treating it like gather(): expecting a plain exception, reading results too early, or sharing a session that should have been per task.

Cancelling a task that is mid-query Four tiles in order. First, CancelledError is raised at the await inside session.execute. Second, the driver sends a cancel request so the server stops work on the statement. Third, because the connection may be mid-protocol, SQLAlchemy does not trust it and invalidates it rather than returning it to the pool as healthy. Fourth, the async with block closes the session and rolls back the transaction. 1 · CancelledError raised at the await inside session.execute() 2 · server-side cancel the statement stops no orphaned query 3 · connection may be mid-protocol invalidated, not reused 4 · session closes transaction rolled back async with does the work Let cancellation propagate. Catching CancelledError and carrying on leaves a session whose connection has already been discarded.
Symptom or exact errorRoot CauseProduction Fix
ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)A task raised, and the group wraps every failure in an ExceptionGroup.Catch with except* IntegrityError: or except* SQLAlchemyError:.
except IntegrityError: never matchesPlain except does not look inside an ExceptionGroup.Use except*, or unwrap eg.exceptions explicitly.
InterfaceError: another operation is in progressThe helpers were passed a shared session instead of opening their own.Open the session inside each task.
InvalidStateError: Result is not set.task.result() called inside the async with block before the task finished.Read results after the block exits.
RuntimeError: TaskGroup ... is finishedtg.create_task() called after the block exited, typically from a callback.Create every task inside the block.
Pool timeouts appear after introducing fan-outEach request now holds several connections at once.Cap tasks per request, or raise pool_size with the database's max_connections in mind.

Exception groups are the part that surprises most teams. A handler that used to catch IntegrityError from a single query needs except* once that query runs inside a group:

import asyncio

from sqlalchemy.exc import DBAPIError, IntegrityError


async def import_batch(rows_by_table):
    try:
        async with asyncio.TaskGroup() as tg:
            for table, rows in rows_by_table.items():
                tg.create_task(load_table(table, rows), name=f"load:{table}")
    except* IntegrityError as group:
        for error in group.exceptions:
            log.warning("duplicate rows skipped", extra={"detail": str(error.orig)})
        raise
    except* DBAPIError as group:
        if any(e.connection_invalidated for e in group.exceptions):
            raise RetryableImportError from group
        raise

Each except* clause receives the subset of failures matching its type, and anything not handled is re-raised in a new group. Naming tasks, as above, makes the group's traceback readable when several tables fail at once.

Advanced: Timeouts, Cancellation and Partial Results

A dashboard that waits indefinitely for its slowest aggregate is worse than one that returns without it. asyncio.timeout() bounds the whole group, and because cancellation flows through the group, a timeout cancels every still-running query rather than just abandoning them.

Three aggregates: sequential vs TaskGroup Bar chart. Sequentially the endpoint takes the sum, about 260 milliseconds, using one connection. In a TaskGroup it takes roughly the slowest query, about 125 milliseconds, using three connections at once. sequential, one session ≈ 260 ms · 1 connection held TaskGroup, three sessions ≈ 125 ms · 3 connections held Parallelism turns the sum into the maximum — and multiplies the connections each request holds.
import asyncio
import datetime as dt
import logging

log = logging.getLogger("dashboard")


async def daily_summary_with_budget(day: dt.date, budget_s: float = 0.5) -> dict:
    results: dict[str, int | None] = {"revenue_cents": None, "orders": None, "refunds": None}

    async def collect(key: str, coro) -> None:
        results[key] = await coro

    try:
        async with asyncio.timeout(budget_s):
            async with asyncio.TaskGroup() as tg:
                tg.create_task(collect("revenue_cents", revenue(day)))
                tg.create_task(collect("orders", order_count(day)))
                tg.create_task(collect("refunds", refunds(day)))
    except TimeoutError:
        missing = [key for key, value in results.items() if value is None]
        log.warning("summary over budget", extra={"missing": missing})
    return results

When the timeout fires, each unfinished task receives CancelledError at its current await. For a task waiting on session.execute(), asyncpg sends PostgreSQL a cancel request so the statement stops on the server too, and SQLAlchemy treats the connection as unreliable — it was interrupted mid-exchange — so it is discarded rather than handed to the next request. The helper's async with Session() block then closes the session and rolls back. The finished tasks' values are already in results.

Two things undermine this. Catching CancelledError inside a helper and continuing leaves a session whose connection is gone, and the next statement fails confusingly. And wrapping a query in asyncio.shield() to "protect" it means the timeout returns while the query keeps its connection for as long as it likes — the exact leak the group was meant to prevent. Let cancellation propagate, and set a server-side statement_timeout as the backstop for queries that should never run long in any context.

A server-side limit is a one-line addition to the engine, and it catches the queries that no Python timeout is watching:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    connect_args={"server_settings": {"statement_timeout": "5000"}},  # milliseconds
)

When Parallel Queries Are the Wrong Tool

Parallelism turns the sum of query times into the maximum, and that is only a win when the queries are independent, similar in duration, and cheap in connections. A surprising number of fan-outs fail at least one of those tests.

Before you parallelise Four questions. Can the queries be combined into one statement with a join, a union or a lateral subquery? Do they need to see the same snapshot of the data? Is the pool large enough for concurrent requests multiplied by tasks per request? Is the slowest query the real bottleneck, so that parallelism would help? could this be one statement? a join, UNION ALL, or scalar subqueries in one SELECT — one connection, one round trip must the reads agree with each other? separate sessions are separate snapshots; totals can disagree by a concurrent commit can the pool afford it? peak requests × tasks per request must stay under pool_size + max_overflow is latency dominated by independent waits? if one query is 90% of the time, parallelism saves only the other 10%

The first alternative to check is a single statement. Three aggregates over the same table are one SELECT with three expressions; three aggregates over different tables are scalar subqueries in one SELECT. PostgreSQL runs them in one round trip on one connection, often faster than three parallel queries because there is no connection overhead at all:

import datetime as dt

from sqlalchemy import func, select

from shop.db import Session
from shop.models import Order, Refund


async def daily_summary_single(day: dt.date) -> dict[str, int]:
    revenue = (
        select(func.coalesce(func.sum(Order.total_cents), 0))
        .where(Order.placed_on == day)
        .scalar_subquery()
    )
    orders = select(func.count(Order.id)).where(Order.placed_on == day).scalar_subquery()
    refunds = select(func.count(Refund.id)).where(Refund.created_on == day).scalar_subquery()
    async with Session() as session:
        row = (await session.execute(select(revenue, orders, refunds))).one()
    return {"revenue_cents": row[0], "orders": row[1], "refunds": row[2]}

The second is caching. An aggregate that changes hourly does not need to be computed on every request, in parallel or otherwise.

Fan-out earns its place when the work genuinely cannot be combined: reads from two different databases, a database read interleaved with an HTTP call, or loading several large, independent result sets for an export. For those, the TaskGroup pattern above is the right shape, and bounding it with a semaphore — as in the guide to fixing "another operation is in progress" errors — keeps it from starving the rest of the service.

Frequently Asked Questions

Should I use TaskGroup or gather() for database queries?

Prefer TaskGroup on Python 3.11 and later. It cancels sibling tasks when one fails, so no query outlives the request, and it reports every failure instead of only the first. gather(return_exceptions=True) is the closest older equivalent, but it does not cancel anything.

Can the tasks in a TaskGroup share one AsyncSession?

No. Tasks in a group run concurrently, so a shared session means a shared connection and InterfaceError: another operation is in progress. Each task opens its own session from the factory.

Do parallel reads see a consistent snapshot?

Not by default. Each session has its own transaction and, under READ COMMITTED, each statement sees data as of its own start. If the numbers must reconcile, combine them into one statement or read them sequentially in one REPEATABLE READ transaction.

Is it safe to write from several tasks at once?

Yes, with a session per task, and each write commits independently. If the writes must succeed or fail together they belong in one transaction on one session, run sequentially.