Codetail

Article 6 of 8

Exception Handling You Are Not Using

raise from, add_note, ExceptionGroup. The modern toolkit.

16 min read

Exception chaining with raise ... from

When a low-level exception causes a high-level one, you want both in the traceback. Without explicit chaining, you get either a confusing implicit chain or a completely lost original cause.

Python
1# Before: implicit chaining -- Python shows both but with messy wording
2def load_config(path: str) -> dict:
3 try:
4 with open(path) as f:
5 import json
6 return json.load(f)
7 except Exception as e:
8 raise RuntimeError(f"Failed to load config: {path}")
9 # The original FileNotFoundError/JSONDecodeError is shown but
10 # Python says "During handling of the above exception, another exception occurred"
11 # That phrasing implies something went wrong in the handler, not a deliberate wrap
Python
1import json
2
3# After: explicit chaining with "raise X from Y"
4def load_config(path: str) -> dict:
5 try:
6 with open(path) as f:
7 return json.load(f)
8 except FileNotFoundError as e:
9 raise RuntimeError(f"Config file not found: {path}") from e
10 except json.JSONDecodeError as e:
11 raise RuntimeError(f"Config file is not valid JSON: {path}") from e
12
13# Python now says "The above exception was the direct cause of the following exception"
14# Clean signal: this was intentional, not an accidental second failure
15
16# Suppress the chain entirely when you don't want the cause shown
17def safe_parse(value: str) -> int:
18 try:
19 return int(value)
20 except ValueError:
21 raise ValueError(f"Expected an integer, got: {value!r}") from None

raise X from Y — explicit cause, shows "The above exception was the direct cause."
raise X from None — suppress the chain entirely. Use when the original error leaks implementation details that callers should not see.

exception.add_note() (3.11)

Sometimes you catch an exception further up the stack and want to attach context without wrapping it in a new exception. Before 3.11, your options were to either re-raise with a new type (losing the original) or mutate args (fragile). Python 3.11 added add_note().

Python
1# Before 3.11: no clean way to annotate an exception you're re-raising
2def process_batch(items: list[str]) -> None:
3 for i, item in enumerate(items):
4 try:
5 process_item(item)
6 except ValueError as e:
7 # Only option: wrap and lose original type
8 raise RuntimeError(f"Failed on item {i}: {item}") from e
Python
1# Python 3.11: add_note() attaches context and keeps the original type
2def process_item(value: str) -> int:
3 return int(value)
4
5def process_batch(items: list[str]) -> None:
6 for i, item in enumerate(items):
7 try:
8 process_item(item)
9 except ValueError as e:
10 e.add_note(f"Failed on item {i}: {item!r}")
11 e.add_note(f"Batch had {len(items)} items total")
12 raise # re-raise the original -- same type, same traceback, plus notes
13
14try:
15 process_batch(["1", "2", "bad", "4"])
16except ValueError as e:
17 print(type(e).__name__) # still ValueError, not RuntimeError
18 for note in e.__notes__:
19 print("Note:", note)

Notes appear at the end of the traceback automatically. The exception type stays the same, which matters for callers catching specific types. This is especially useful in test frameworks and CLI tools that want to add diagnostic context without changing control flow.

ExceptionGroup and except* (3.11)

Concurrent code can fail in multiple places at once. Before 3.11, the standard approach was to collect errors in a list and raise one summary exception. That made it impossible for callers to catch specific sub-errors without parsing the message. Python 3.11 introduced ExceptionGroup and except* to handle this properly.

Python
1# Before: collect-and-summarize pattern -- callers can't easily introspect
2def validate_all(data: list[dict]) -> None:
3 errors = []
4 for i, item in enumerate(data):
5 if "name" not in item:
6 errors.append(ValueError(f"item {i}: missing 'name'"))
7 if "age" not in item:
8 errors.append(KeyError(f"item {i}: missing 'age'"))
9 if errors:
10 raise RuntimeError(f"{len(errors)} validation errors: {errors}")
Python
1# Python 3.11: ExceptionGroup carries multiple exceptions properly
2def validate_all(data: list[dict]) -> None:
3 errors = []
4 for i, item in enumerate(data):
5 if "name" not in item:
6 errors.append(ValueError(f"item {i}: missing 'name'"))
7 if "age" not in item:
8 errors.append(KeyError(f"item {i}: missing 'age'"))
9 if errors:
10 raise ExceptionGroup("validation failed", errors)
11
12# except* routes each exception type to its own handler
13try:
14 validate_all([
15 {"name": "Alice", "age": 30},
16 {"age": 25}, # missing name
17 {"name": "Charlie"}, # missing age
18 {}, # missing both
19 ])
20except* ValueError as eg:
21 print(f"ValueError group ({len(eg.exceptions)} errors):")
22 for e in eg.exceptions:
23 print(f" {e}")
24except* KeyError as eg:
25 print(f"KeyError group ({len(eg.exceptions)} errors):")
26 for e in eg.exceptions:
27 print(f" {e}")

Unlike regular except which stops at the first matching clause, except* runs all matching handlers. Each handler receives an ExceptionGroup containing only the exceptions of the matching type. Both handlers above run because the group contained both ValueError and KeyError.

Smarter built-in error messages (3.10+)

Python 3.10 through 3.12 significantly improved the error messages from the interpreter itself. The code does not change: the same mistake that gave you a cryptic message in 3.9 now gives you a helpful suggestion in 3.11.

AttributeError with suggestions (3.10)

Python
1# Python 3.9 and earlier:
2# AttributeError: 'list' object has no attribute 'appendd'
3
4# Python 3.10+:
5items = [1, 2, 3]
6items.appendd(4)

NameError with suggestions (3.10)

Python
1# Python 3.9:
2# NameError: name 'lenght' is not defined
3
4# Python 3.10+: checks builtins and local scope
5my_list = [1, 2, 3]
6print(lenght(my_list))

SyntaxError improvements (3.10)

Python
1# Python 3.9 and earlier:
2# SyntaxError: invalid syntax (pointing at the wrong token)
3
4# Python 3.10+ gives precise pointers and explanations
5# Example: missing colon after if
6# if x > 0
7# ^
8# SyntaxError: expected ':'
9
10# Example: using = instead of == in comparison
11# if x = 5:
12# SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?

Tracebacks with exact column highlighting (3.11)

Python
1# Python 3.11 highlights the exact subexpression that failed, not just the line
2
3def compute(a, b, c):
4 return a + b * c
5
6result = compute(1, None, 3)

These improvements compound. A junior developer reading a 3.11 traceback gets substantially more information than the same crash on 3.9. If you are supporting Python 3.9 or 3.10, upgrading is often worth it for the debugging experience alone.