Python ExceptionGroup: Multiple Errors

Published on: August 28, 2026
Reading time: 4 minutes
A conceptual image showing error code projected on binary data with smoke.

Concurrent programs and batch operations may produce several failures at once. Older code often had to select one exception, chain errors manually, or return a list of failures. ExceptionGroup raises multiple exceptions while preserving their structure, and except* handles selected exception types inside the group.

This guide explains group construction, tracebacks, partial handling, nested groups, asyncio.TaskGroup, filtering, logging, API compatibility, and reliable tests.

Your first ExceptionGroup

errors = [
    ValueError("invalid age"),
    KeyError("email"),
    RuntimeError("service unavailable"),
]

raise ExceptionGroup("processing failures", errors)

The traceback displays the outer context and every child exception. The main message explains the larger operation.

Why not return a list?

A returned list requires every caller to remember to inspect it and does not integrate with normal exception flow. Raising only the first error loses information. ExceptionGroup keeps failures in the exception mechanism and supports type-based handling.

Handling errors with except*

try:
    raise ExceptionGroup(
        "batch",
        [ValueError("A"), TypeError("B"), ValueError("C")],
    )
except* ValueError as group:
    for error in group.exceptions:
        print("invalid value:", error)
except* TypeError as group:
    for error in group.exceptions:
        print("invalid type:", error)

Each except* block receives a subgroup containing matching exceptions while preserving relevant nesting.

except and except* are different

except ExceptionGroup catches the group object itself. except* searches for exception types inside it.

try:
    run_batch()
except ExceptionGroup as group:
    print("direct children:", len(group.exceptions))

Catch the whole group for logging, transformation, or manual inspection. Use except* to handle categories.

Partial handling

Matching exceptions are handled; unmatched exceptions continue propagating.

try:
    raise ExceptionGroup(
        "operations",
        [ValueError("input"), OSError("disk")],
    )
except* ValueError:
    print("repairing input")

The unhandled OSError is re-raised in a group. This prevents a handler from hiding failures it cannot resolve.

Nested groups

ExceptionGroups may contain other groups.

group = ExceptionGroup(
    "application",
    [
        ExceptionGroup(
            "validation",
            [ValueError("name"), ValueError("email")],
        ),
        ExceptionGroup(
            "infrastructure",
            [TimeoutError("API"), OSError("file")],
        ),
    ],
)

The structure can represent subsystems, tasks, or stages. except* preserves that structure while selecting matches.

ExceptionGroup and TaskGroup

asyncio.TaskGroup may collect failures from child tasks into an ExceptionGroup.

import asyncio

async def fail_value():
    await asyncio.sleep(0)
    raise ValueError("invalid response")

async def fail_io():
    await asyncio.sleep(0)
    raise OSError("connection lost")

async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(fail_value())
            group.create_task(fail_io())
    except* ValueError as errors:
        print("validation:", errors)
    except* OSError as errors:
        print("infrastructure:", errors)

Because the first failure may cancel siblings, not every task necessarily reaches its own exception. The group represents failures observed during structured shutdown.

BaseExceptionGroup

BaseExceptionGroup may contain exceptions derived directly from BaseException, such as KeyboardInterrupt and SystemExit. ExceptionGroup accepts only Exception instances.

Most application code should create ExceptionGroup. System-exit exceptions have special semantics and should not be handled like routine failures.

Building a group only when needed

def validate_records(records: list[dict]) -> None:
    errors: list[Exception] = []

    for index, record in enumerate(records):
        try:
            validate_record(record)
        except ValueError as exc:
            exc.add_note(f"record at index {index}")
            errors.append(exc)

    if errors:
        raise ExceptionGroup("invalid records", errors)

add_note() attaches per-error context without replacing the original message.

Exception notes

try:
    convert(value)
except ValueError as exc:
    exc.add_note(f"field: {field}")
    exc.add_note(f"file: {filename}")
    raise

In batch systems, include an index, identifier, path, or task name. Avoid sensitive data.

Filtering with subgroup()

subgroup() selects exceptions that satisfy a condition.

io_only = group.subgroup(lambda error: isinstance(error, OSError))
if io_only is not None:
    print(io_only)

The result preserves matching branches of the original group.

Splitting with split()

split() returns matching and remaining groups.

io, others = group.split(OSError)

if io is not None:
    log_io(io)
if others is not None:
    raise others

This is useful in middleware and libraries that handle one category while preserving the rest.

derive() and subclasses

Libraries may subclass ExceptionGroup to carry metadata. Filtering operations can call an overridden derive() method to preserve the subclass.

class BatchErrors(ExceptionGroup):
    def __new__(cls, message, exceptions, batch_id):
        obj = super().__new__(cls, message, exceptions)
        obj.batch_id = batch_id
        return obj

    def derive(self, exceptions):
        return BatchErrors(self.message, exceptions, self.batch_id)

Ordinary application code usually does not need a custom subclass.

Captured subgroups are temporary views

The object received by except* is a subgroup for that handler. Mutating attributes on it does not rewrite the group that will continue propagating. Add context to individual exceptions or raise a new exception with explicit chaining.

Raising new errors inside except*

try:
    run()
except* ValueError as errors:
    raise RuntimeError("batch validation failed") from errors

Newly raised exceptions and unhandled original exceptions are combined according to language rules. Preserve the rich cause rather than replacing it with an unexplained generic error.

Logging groups

Standard logging can produce long but useful tracebacks. Keep the full exception for diagnosis and separately aggregate metrics by type.

from collections import Counter


def count_types(group: BaseExceptionGroup) -> Counter[str]:
    counts: Counter[str] = Counter()

    def visit(exc: BaseException) -> None:
        if isinstance(exc, BaseExceptionGroup):
            for child in exc.exceptions:
                visit(child)
        else:
            counts[type(exc).__name__] += 1

    visit(group)
    return counts

Do not discard the original structure; it may reveal which subtask produced each failure.

Public API compatibility

A function that previously raised one ValueError and begins raising ExceptionGroup has changed its contract. Callers using except ValueError will not automatically handle a ValueError contained inside a group.

Document the change, version the API, and consider a fail-fast mode when compatibility matters.

Fail fast or collect all?

Collecting all failures is useful in form validation, migrations, compilation, and audits. Fail-fast behavior is better when continuing is expensive, unsafe, or adds no useful information.

ExceptionGroup does not require collection; it provides a proper representation when multiple failures genuinely need to be communicated.

Testing ExceptionGroup

Pytest can capture and inspect the group.

import pytest


def test_batch_validation():
    with pytest.raises(ExceptionGroup) as captured:
        validate_records([{}, {}])

    group = captured.value
    assert group.message == "invalid records"
    assert len(group.exceptions) == 2
    assert all(isinstance(e, ValueError) for e in group.exceptions)

For nested groups, assert relevant types, notes, and structure rather than the exact formatted traceback.

Version compatibility

ExceptionGroup and except* belong to modern Python. Projects supporting older releases may use the exceptiongroup backport for the object, but except* syntax requires language support.

Declare the minimum version and verify linting, type checking, test runners, and coverage tools.

Common mistakes

  • Catching only Exception: it does not selectively handle inner types.
  • Suppressing the remainder: unresolved failures should continue.
  • Flattening groups without need: preserve structure for diagnosis.
  • Using a group for every single failure: a normal exception may be clearer.
  • Collecting when continuing is unsafe: choose consciously between batch and fail fast.
  • Changing API behavior without documentation: callers need the new contract.

Complete example: batch import

from pathlib import Path


def import_file(path: Path) -> list[dict]:
    results: list[dict] = []
    errors: list[Exception] = []

    for number, line in enumerate(path.read_text().splitlines(), start=1):
        try:
            results.append(parse_line(line))
        except (ValueError, KeyError) as exc:
            exc.add_note(f"line {number}")
            exc.add_note(f"file {path.name}")
            errors.append(exc)

    if errors:
        raise ExceptionGroup(
            f"failed to import {path.name}",
            errors,
        )

    return results

try:
    import_file(Path("customers.txt"))
except* ValueError as errors:
    print("invalid values:", len(errors.exceptions))
except* KeyError as errors:
    print("missing fields:", len(errors.exceptions))

The caller receives every relevant failure with line and file context and can handle categories independently.

Conclusion

ExceptionGroup represents multiple failures without losing structure, while except* enables selective handling. It is especially useful for structured concurrency, validation, and batch work.

The official Python ExceptionGroup documentation and the except* reference explain the rules. Use groups when multiple failures matter, preserve unhandled errors, and keep enough context for diagnosis.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python typing.Self: Fluent Return Types

    Learn Python typing.Self for fluent methods, classmethods, builders, clones, Protocol, context managers, generics, and subclass-preserving returns.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Path.walk: Traverse Directories

    Learn Python Path.walk to traverse directories, prune folders, handle errors and symlinks, calculate sizes, delete safely, and support older versions.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A developer typing code on a laptop with a Python book beside in an office.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python asyncio.timeout: Control Deadlines

    Learn Python asyncio.timeout for deadlines, timeout_at, rescheduling, TaskGroup, cleanup, retries, shielding, and safe asynchronous cancellation.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A detailed view of computer programming code on a screen, showcasing software development.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TaskGroup: Structured Concurrency

    Learn Python TaskGroup for structured concurrency, task results, cancellation, ExceptionGroup, timeouts, nested groups, and bounded async work.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python MappingProxyType: Read-Only Dict

    Learn Python MappingProxyType to expose read-only dictionaries, create dynamic views and snapshots, preserve invariants, and avoid unnecessary copies.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    Close-up of a metal gate featuring a 'Please Keep Gate Closed' sign, outdoors.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Literal: Restrict Exact Values

    Learn Python Literal to restrict exact values, create overloads, discriminate TypedDict variants, use match/case, and improve typed APIs.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026