skip to content

How do ExceptionGroup and BaseExceptionGroup differ, and when do you need the base one?

level: middleimportance: should knowfreq 28%

answer

  1. It mirrors the ordinary hierarchy
  2. One of them can hold cancellation
  3. Constructing the base one may narrow it
  4. except Exception misses the base variant
  5. TypeError: cannot nest BaseExceptions

basics

~10 s

BaseExceptionGroup derives from BaseException and can hold any exception, including KeyboardInterrupt, SystemExit and asyncio.CancelledError. ExceptionGroup derives from Exception and rejects those with a TypeError. Constructing BaseExceptionGroup with only ordinary exceptions returns an ExceptionGroup.

solid answer

~40 s

The pair mirrors the ordinary hierarchy. `BaseExceptionGroup` derives from `BaseException` and may hold any exception; `ExceptionGroup` derives from both `BaseExceptionGroup` and `Exception` and may only hold `Exception` instances — passing a `KeyboardInterrupt` raises `TypeError: Cannot nest BaseExceptions in an ExceptionGroup`. The constructor narrows automatically: `BaseExceptionGroup('m', [ValueError('a')])` actually returns an `ExceptionGroup`, so you can construct with the base name and still get the catchable type when the contents allow it. The consequence that matters in production is asymmetric catching. `except Exception` and `except ExceptionGroup` will not catch a group carrying `asyncio.CancelledError`, and `except* Exception` will not match that member, so cancellation keeps propagating as intended — which is the behaviour you want, and the reason cleanup code that must run for anything should name `BaseExceptionGroup`.

code

python · 7 lines
python
g = BaseExceptionGroup('m', [ValueError('a')])
print(type(g).__name__, isinstance(g, Exception))

try:
    ExceptionGroup('m', [KeyboardInterrupt()])
except TypeError as exc:
    print('rejected:', exc)

go deeper

for a junior

Recall which is which: the Base one descends from BaseException and can hold anything, the other descends from Exception and holds only ordinary errors. Building the base one from ordinary errors gives you back the narrower type.

for a middle

Explain the mirror of the ordinary hierarchy, the automatic narrowing in the constructor, and the TypeError that stops a control-flow signal being smuggled into a catchable group. Show which catch clause misses which group.

for a senior

Demonstrate the operational consequence: broad handlers and cleanup paths must name BaseExceptionGroup and must re-raise, or a service stops honouring cancellation and shutdown. Test group-ness with isinstance, never an exact type comparison.

for a principal

Own the convention across services: which layer is allowed to aggregate into a group, whether library-specific group subclasses are worth their derive override, and how supervision code distinguishes real failure from an orderly shutdown signal.

### The hierarchy, in one line each Python's ordinary exception hierarchy separates `Exception` — errors your program is meant to handle — from the rest of `BaseException`: `KeyboardInterrupt`, `SystemExit`, `GeneratorExit`, and by convention `asyncio.CancelledError`, which are control-flow signals a broad handler must not swallow. Exception groups mirror that split exactly: * `BaseExceptionGroup` derives from `BaseException`. It can hold any exception at all. * `ExceptionGroup` derives from **both** `BaseExceptionGroup` and `Exception`. It can hold only instances of `Exception`. The MRO is `ExceptionGroup -> BaseExceptionGroup -> Exception -> BaseException -> object`, so an `ExceptionGroup` is a `BaseExceptionGroup`, but not the other way round. ### The constructor narrows for you A detail worth knowing because it removes a class of bugs: `BaseExceptionGroup.__new__` inspects the members, and if every one of them is an `Exception` it returns an `ExceptionGroup` instead: ```python g = BaseExceptionGroup('m', [ValueError('a')]) type(g).__name__ # 'ExceptionGroup' isinstance(g, Exception) # True ``` That means generic code — a helper that collects failures from a fan-out and raises them together — can always construct `BaseExceptionGroup` and still hand ordinary callers a catchable `ExceptionGroup` in the common case, without inspecting the members itself. The reverse is enforced rather than silently coerced. `ExceptionGroup('m', [KeyboardInterrupt()])` raises `TypeError: Cannot nest BaseExceptions in an ExceptionGroup`. There is no way to sneak a control-flow signal into a group that `except Exception` would catch, which is precisely the guarantee the split exists to give. ### Where the difference bites Suppose a fan-out is interrupted partway: some children failed with ordinary errors, and one was cancelled. The group that propagates must be a `BaseExceptionGroup`, because it holds an `asyncio.CancelledError`. Now: * `except Exception:` does **not** catch it. A broad "log and continue" handler higher up will not run, and the cancellation continues to propagate — correct behaviour, and a common surprise. * `except ExceptionGroup:` does not catch it either, for the same reason. Code that means "any group" must say `except BaseExceptionGroup:`. * `except* Exception:` matches the ordinary members and hands them over, while the `CancelledError` stays unmatched and is re-raised as a `BaseExceptionGroup` after the try statement. You get to clean up after the real errors without accidentally cancelling the cancellation. The same reasoning applies to `KeyboardInterrupt` during a batch and to `SystemExit` raised from a worker: the group type follows its worst member, and broad handlers stay honest. ### Practical rules * Raise `ExceptionGroup` when you are aggregating ordinary errors — validation failures, per-item processing errors. That is nearly always what you want, and callers can catch it with `except Exception`. * Use `BaseExceptionGroup` when the members can include control-flow signals, or when writing generic aggregation code where you cannot know; the constructor will narrow to `ExceptionGroup` whenever the contents allow. * In cleanup and top-level supervision code, catch `BaseExceptionGroup`, not `ExceptionGroup`, or you will miss exactly the cases where cleanup matters most. Then re-raise: catching a group that carries a cancellation and not putting it back is how a service becomes unstoppable. * Do not test group-ness with `type(eg) is ExceptionGroup`. Use `isinstance(eg, BaseExceptionGroup)`, which covers both builtins and any subclass, including the ones a library defines to carry extra state. ### Catching broadly, deliberately `except* BaseException` does match a cancellation or a `KeyboardInterrupt` sitting inside a group, and it is occasionally the right tool — a supervisor that must record every terminated child before letting the signal continue on its way. Treat it exactly as you treat a bare `except BaseException`: handle, do not swallow, and end the clause with a bare `raise` so the subgroup rejoins the leftovers and keeps propagating. The narrower and far more common form is `except* Exception` around the errors you actually own, leaving control-flow signals to the layer that asked for them. ### Subclassing Both types can be subclassed, which is how a library ships a domain-specific group. A subclass of `ExceptionGroup` must accept the `(message, exceptions)` pair, and if it carries extra state it should override `derive`, the hook that `split` and `subgroup` call when they build the partial groups — otherwise the extra state is lost the first time somebody splits your group. A subclass of `BaseExceptionGroup` alone does not get the automatic narrowing; that behaviour belongs to the base constructor deciding between the two builtins. ### Version note Both builtins arrived in Python 3.11 (PEP 654), including the constructor narrowing and the `TypeError` on nesting a `BaseException` in an `ExceptionGroup`. Nothing here changed through 3.14.

  • What does `except* Exception` do to a group that contains an asyncio.CancelledError?
    It matches every ordinary member and hands them to the handler, but `CancelledError` derives from `BaseException`, so it is not matched. When the try statement ends, the cancellation is re-raised as a `BaseExceptionGroup`. That is the desired outcome: you clean up after the real errors and the cancellation still reaches whoever requested it.
  • Why does `except ExceptionGroup` sometimes miss a group entirely?
    Because the raised object may be a `BaseExceptionGroup`, which is not an `ExceptionGroup` — the subclass relationship runs the other way. Any code that means "any exception group" must catch `BaseExceptionGroup`, or better, test `isinstance(exc, BaseExceptionGroup)` rather than comparing types exactly, so library subclasses are covered too.
  • If a library subclasses ExceptionGroup to carry extra state, what must it implement?
    It must accept the `(message, exceptions)` constructor pair, and it should override `derive`, which `split` and `subgroup` call to build each partial group. Without that override the extra state is dropped the first time anybody partitions the group, and the halves come back as plain groups instead of the library's own type.

saying these in an interview costs you the question

  • Says ExceptionGroup is the parent of BaseExceptionGroup
  • Claims except Exception catches any exception group
  • Thinks a KeyboardInterrupt can go into an ExceptionGroup
  • Uses type(eg) is ExceptionGroup as the group test
  • Catches a group with a cancellation and never re-raises it
  • Assumes BaseExceptionGroup(...) always returns that exact type

context