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}")
raiseIn 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 othersThis 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 errorsNewly 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 countsDo 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.







