Raising, Chaining, and Groups

Updated

September 8, 2026

Raising, Chaining, and Groups

raise is how you fail on purpose. raise ... from e chains the cause so the traceback shows both the KeyError and the ValueError you wrapped it in. raise ... from None hides the cause when it is noise. An exception group is several errors that happened together; except* unpacks them. The boring default is one specific exception, chained when you wrap, grouped when a batch has more than one independent failure.

Mental model

raise ValueError("...") builds a traceback from this frame up.

Inside except KeyError as e:, raise ValueError("...") from e sets __cause__. Python prints “The above exception was the direct cause of the following exception.” That is what you want when a missing shift name is a missing dict key.

from None sets __suppress_context__. Use it when the builtin error would confuse the reader (KeyError: 'brunch' after you already said no shift named 'brunch').

An ExceptionGroup(msg, [e1, e2, ...]) is one object that contains others. except ValueError does not match the group. except* ValueError matches the inner ValueErrors, possibly splitting the group. Use groups when you validate several fields and want every problem, not only the first.

Worked examples

Case 1: raise a specific error

Save as raise_plain.py. Guard a rule. Catch it at the edge and print the message.

# raise_plain.py
def open_table(n):
    if n <= 0:
        raise ValueError(f"table {n}: number must be positive")
    return f"opened {n}"


def main():
    print(open_table(12))
    try:
        open_table(0)
    except ValueError as e:
        print(e)


if __name__ == "__main__":
    main()

Run:

uv run python raise_plain.py

Output:

opened 12
table 0: number must be positive

raise ValueError without a message is legal and rude. Put the bad value in the string.

Case 2: chain the cause

Save as chain.py. The first line of output is the successful lookup. Then the process exits with a chained traceback.

# chain.py
def hours_for(name):
    known = {"open": 4, "mid": 6, "close": 5}
    try:
        return known[name]
    except KeyError as e:
        raise ValueError(f"no shift named {name!r}") from e


def main():
    print(hours_for("open"))
    hours_for("brunch")


if __name__ == "__main__":
    main()

Run:

uv run python chain.py

Output (the process exits non-zero):

4
Traceback (most recent call last):
  File "chain.py", line 5, in hours_for
    return known[name]
           ~~~~~^^^^^^
KeyError: 'brunch'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "chain.py", line 16, in <module>
    main()
    ~~~~^^
  File "chain.py", line 12, in main
    hours_for("brunch")
    ~~~~~~~~~^^^^^^^^^^
  File "chain.py", line 7, in hours_for
    raise ValueError(f"no shift named {name!r}") from e
ValueError: no shift named 'brunch'

Read from the top: the KeyError, then the ValueError you meant the caller to see. Line numbers follow the file you saved.

Case 3: hide a cause that adds nothing

Save as chain_none.py. Same wrap, no inner traceback.

# chain_none.py
def hours_for(name):
    known = {"open": 4, "mid": 6}
    try:
        return known[name]
    except KeyError:
        raise ValueError(f"no shift named {name!r}") from None


def main():
    hours_for("brunch")


if __name__ == "__main__":
    main()

Run:

uv run python chain_none.py

Output (the process exits non-zero):

Traceback (most recent call last):
  File "chain_none.py", line 15, in <module>
    main()
    ~~~~^^
  File "chain_none.py", line 11, in main
    hours_for("brunch")
    ~~~~~~~~~^^^^^^^^^^
  File "chain_none.py", line 7, in hours_for
    raise ValueError(f"no shift named {name!r}") from None
ValueError: no shift named 'brunch'

Use from None when the inner type is an implementation detail. Use from e when a log of the original error would help.

Case 4: a group and except*

Save as check_order.py. Collect every field error. Raise one group. except* walks the ValueErrors.

# check_order.py
def check_order(table, cents, seats):
    errors = []
    if table <= 0:
        errors.append(ValueError(f"table {table}"))
    if cents < 0:
        errors.append(ValueError(f"cents {cents}"))
    if seats <= 0:
        errors.append(ValueError(f"seats {seats}"))
    if errors:
        raise ExceptionGroup("bad order", errors)
    return "ok"


def main():
    print(check_order(12, 850, 4))
    try:
        check_order(0, -10, 2)
    except* ValueError as group:
        for e in group.exceptions:
            print(e)


if __name__ == "__main__":
    main()

Run:

uv run python check_order.py

Output:

ok
table 0
cents -10

seats was 2, so it is not in the group. Two problems, one raise, both printed. except ValueError would miss the group. except ExceptionGroup would catch the wrapper; except* is the handler that talks about the inner types.

If a group also contained a TypeError, except* ValueError would handle the ValueErrors and re-raise a group of what remains.

The trap

raise e after except ValueError as e (bare re-raise of a caught instance) resets the traceback in surprising ways. To re-raise the same error, write raise with nothing after it.

Wrapping without from still attaches an implicit context (__context__) and prints “During handling of the above exception, another exception occurred.” That sentence is for a bug in the except block, not for a wrap you meant. If you wrap, say from e or from None on purpose.

Building an ExceptionGroup for a single error is theatre. raise errors[0] if the list has one item, or always group — pick one style for the validator and keep it.

The boring rule

  • Raise a specific type with the bad value in the message.
  • When wrapping, use from e or from None. Do not leave the default context by accident.
  • raise with no operand re-raises the active exception.
  • Validate a batch with ExceptionGroup and handle with except*.
  • Do not use except* for ordinary single exceptions.
  • Do not raise strings. Do not raise BaseException subclasses you do not own.

Try this

  1. In raise_plain.py, raise ValueError for n > 50 as well (“too many tables”).
  2. In chain.py, catch ValueError in main and print e.__cause__.
  3. Switch chain.py to from None and compare the traceback to chain_none.py.
  4. Call check_order(0, -10, 0) so all three fields fail. Print how many items are in group.exceptions.