What is Python's ExceptionGroup, and what raises one in the standard library?
answer
- Only one failure used to survive
- Several errors, one raised object
- A message plus a tuple of exceptions
- New in 3.11, PEP 654
- Plain except does not see inside
basics
~10 sExceptionGroup is a built-in exception added in Python 3.11 that carries several exceptions at once in its exceptions tuple. asyncio.TaskGroup raises one when its child tasks fail, so no failure is silently discarded.
solid answer
~40 sPython propagates one exception at a time, so before 3.11 an operation that fanned out had to pick one failure and drop the rest. PEP 654 added two builtins, `ExceptionGroup` and `BaseExceptionGroup`, built from a message and a non-empty sequence of exception instances and exposed as `.message` and `.exceptions`. Groups nest, each contained exception keeps its own traceback, and the default rendering prints the whole thing as a tree. `asyncio.TaskGroup` is the stdlib producer: when children fail it raises a group holding every failure. The trap is that a group is not an instance of what it holds — `except ValueError` will not catch a group containing a `ValueError`. Either catch the group and inspect it, or use `except*`, which matches by type inside the group.
code
python · 12 linesdef extract():
raise ExceptionGroup(
'extraction failed',
[ValueError('bad timestamp'), OSError('missing track')],
)
try:
extract()
except* ValueError as eg:
print('recoverable:', eg.exceptions)
except* OSError as eg:
print('io:', eg.exceptions)go deeper
Recall the shape: a built-in exception, new in 3.11, holding a message and a tuple of other exceptions, raised when one operation produced several failures. Know that a plain except of a member type will not catch it.
Be ready to explain why a group is not an instance of its members, what asyncio.TaskGroup raises, and how except* differs from except. Mention that each leaf keeps its own traceback and that groups can nest.
An interviewer expects you to treat adopting groups as a contract change: callers' existing handlers stop matching. Talk about where you deliberately raise one, and how the tree rendering keeps a fan-out failure readable in logs.
Own the policy question: which layers in your codebase are allowed to raise groups, and where a group must be reduced back to a single failure before crossing an API boundary that older clients or non-Python consumers still parse.
### One failure won, and the rest were lost Python propagates exactly one exception object at a time. That is fine for sequential code, where the first failure ends the operation, but it breaks down as soon as one logical operation fans out into many independent ones: a set of concurrent tasks, a batch of files, a fan-out to several backends. Before Python 3.11 the choices were to raise the first failure and discard the others, to invent a private container class that no caller recognised, or to log the extras and hope somebody read the log. PEP 654, shipped in **Python 3.11**, added two builtin exception types that carry several exceptions at once, plus the `except*` syntax for handling them. ### The two builtins `BaseExceptionGroup` derives from `BaseException` and can hold any exception. `ExceptionGroup` derives from both `BaseExceptionGroup` and `Exception`, and may only hold instances of `Exception`. Both are constructed the same way: ```python eg = ExceptionGroup('extraction failed', [ValueError('bad timestamp'), OSError('no track')]) ``` The first argument is a message, exposed as `.message`. The second is a non-empty sequence of *already-instantiated* exceptions, exposed as the tuple `.exceptions`. Each contained exception keeps its own `__traceback__`, `__cause__`, `__context__` and `__notes__`: a group is not a summary of what went wrong, it is the real exception objects boxed together, and a debugger or a traceback printer can still walk into each one. A group may contain other groups, and nesting is meaningful rather than accidental — it records the shape of the failure, so that "stage two produced three failures, one of which was itself a fan-out" survives into the log. The default traceback rendering prints that tree, numbering each leaf under `+-+----------------` markers, which is why a group in production output is readable instead of being one flattened wall of text. ### What raises one in the standard library The producer to name is `asyncio.TaskGroup`, also new in 3.11: when the tasks started inside an `async with` block fail, leaving the block raises an exception group containing every child failure, so nothing is dropped on the floor. Note what did *not* change: `asyncio.gather` keeps its old contract — with `return_exceptions=False` it propagates the first failure, and with `return_exceptions=True` it returns exception objects as ordinary results. Outside asyncio, groups are mostly raised by your own code and by libraries that fan work out; the point of putting the types in builtins is that everybody raises the same shape instead of each project inventing one. ### The catch that makes `except*` necessary A group is not an instance of what it contains: ```python eg = ExceptionGroup('m', [ValueError('bad')]) isinstance(eg, ValueError) # False ``` So `except ValueError` will not catch it, and neither will `except (ValueError, OSError)`. An ordinary `except` clause matches the type of the propagating object, and the propagating object is the group. That leaves two ways to handle one: * catch the group itself — `except ExceptionGroup as eg:` — and walk `eg.exceptions`, or call `eg.split(...)` / `eg.subgroup(...)` to partition it; * write `except* ValueError:`, which matches *inside* the group, hands the clause a group of the matching leaves, and re-raises whatever no clause matched. This is the single most common misunderstanding about groups, and it has a real consequence: introducing them is a behavioural change, not a free upgrade. A function that starts raising an `ExceptionGroup` where it used to raise a bare `ValueError` will sail straight past every existing `except ValueError` in its callers. ### When to raise one yourself Raise a group when one logical operation genuinely produced several independent failures and the caller needs all of them: validating every field of a payload instead of stopping at the first, processing a batch, or closing a set of resources where more than one close can fail. Do not reach for it when there is a single failure with a cause behind it — that is exception chaining, a different mechanism. And do not use a group as a general-purpose error container that gets passed around and inspected; it is designed to be raised. ### Version note Everything here is 3.11 or later. On 3.10 the names `ExceptionGroup` and `BaseExceptionGroup` do not exist, and `except*` is a syntax error rather than an unsupported feature, so code that must still run on 3.10 cannot use the syntax at all. The semantics are unchanged through 3.14.
- Why does an existing `except ValueError` handler stop working once a function starts raising an ExceptionGroup?Because the propagating object is the group, and the group is not a `ValueError` — it merely holds one. An `except` clause matches the type of the object being raised, so the handler never fires and the group escapes. You either catch `ExceptionGroup` itself and inspect `.exceptions`, or switch the handler to `except* ValueError`, which matches by type inside the group.
- Does asyncio.gather raise an ExceptionGroup when several awaitables fail?No. `asyncio.gather` keeps its pre-3.11 contract: with `return_exceptions=False` it propagates the first exception and leaves the other failures to be collected separately, and with `return_exceptions=True` it returns the exception objects as ordinary results. `asyncio.TaskGroup` is the API that reports every child failure together as one group.
- What is inside .exceptions — exception classes or instances?Instances, always. The constructor takes already-raised or already-constructed exception objects, and each one keeps its own traceback, cause, context and notes. That is why a group can be rendered as a tree of full tracebacks rather than a list of type names, and why you can re-raise a leaf without losing where it came from.
A group is a parcel of complaint letters rather than a single letter. Opening the parcel is what lets you answer the ones you can and forward the rest; addressing your reply to one letter's sender never opens the parcel at all.
saying these in an interview costs you the question
- Says except ValueError catches a group containing a ValueError
- Thinks exception groups existed before Python 3.11
- Claims the leaves lose their individual tracebacks
- Confuses a group with chaining one exception from another
- Assumes asyncio.gather raises an exception group
- Passes exception classes rather than instances to the constructor