Beyond try/except: the full class tree, designing your own error hierarchy, chaining, notes, exception groups, warnings and the patterns (EAFP, retry) that production code relies on.
The class tree
BaseException
├── SystemExit ← sys.exit()
├── KeyboardInterrupt ← Ctrl-C
├── GeneratorExit ← generator.close()
├── BaseExceptionGroup
└── Exception ← everything you normally catch
├── ArithmeticError → ZeroDivisionError, OverflowError
├── LookupError → KeyError, IndexError
├── OSError → FileNotFoundError, PermissionError, TimeoutError, ConnectionError…
├── ValueError → UnicodeDecodeError…
├── TypeError, AttributeError, NameError, ImportError (→ ModuleNotFoundError)
├── RuntimeError → RecursionError, NotImplementedError
├── StopIteration, StopAsyncIteration
├── ExceptionGroup
└── Warning → DeprecationWarning, UserWarning, SyntaxWarning…print(issubclass(KeyboardInterrupt, Exception)) # False
print(issubclass(KeyboardInterrupt, BaseException)) # True
print(issubclass(KeyError, LookupError), issubclass(ZeroDivisionError, ArithmeticError)) # True True
print([c.__name__ for c in FileNotFoundError.__mro__])
# ['FileNotFoundError', 'OSError', 'Exception', 'BaseException', 'object']import sys
try:
sys.exit(2) # sys.exit just raises SystemExit
except SystemExit as e:
print("exit code", e.code) # exit code 2Designing a custom hierarchy
A library should raise its own base exception, with specific subclasses underneath. Callers can then catch everything from your library in one clause, or one precise failure.
class PaymentError(Exception):
"""Base class for every error this library raises."""
class CardDeclined(PaymentError):
def __init__(self, reason):
super().__init__(f"card declined ({reason})")
self.reason = reason
class GatewayTimeout(PaymentError):
pass
def charge(amount):
if amount < 0:
raise ValueError("amount must be >= 0") # caller bug → a built-in error
if amount > 1000:
raise CardDeclined("limit")
return "ok"
try:
charge(5000)
except PaymentError as e: # the whole family
print(type(e).__name__, "|", e, "|", e.reason) # CardDeclined | card declined (limit) | limitCatching several types & re-raising
def to_int(x):
try:
return int(x)
except (TypeError, ValueError) as e: # a tuple catches any of them
return type(e).__name__
print(to_int("7"), to_int("x"), to_int(None)) # 7 ValueError TypeError
log = []
def load(key):
try:
return {"a": 1}[key]
except KeyError:
log.append(f"missing {key}") # do something useful…
raise # …then re-raise the SAME exception
try:
load("b")
except KeyError as e:
print(repr(e), log) # KeyError('b') ['missing b']try:
1 / 0
except ZeroDivisionError as e:
saved = e
print("e" in dir(), type(saved).__name__) # False ZeroDivisionErrorChaining: __context__ vs __cause__
def implicit():
try:
1 / 0
except ZeroDivisionError:
raise ValueError("bad ratio") # error while handling another
def explicit():
try:
1 / 0
except ZeroDivisionError as e:
raise ValueError("bad ratio") from e # "this caused that"
def hidden():
try:
1 / 0
except ZeroDivisionError:
raise ValueError("bad ratio") from None # hide the internal detail
def inspect(f):
try:
f()
except ValueError as e:
return type(e.__cause__).__name__, type(e.__context__).__name__, e.__suppress_context__
print(inspect(implicit)) # ('NoneType', 'ZeroDivisionError', False)
print(inspect(explicit)) # ('ZeroDivisionError', 'ZeroDivisionError', True)
print(inspect(hidden)) # ('NoneType', 'ZeroDivisionError', True)| You write | Attribute set | Traceback says |
|---|---|---|
raise New() inside except | __context__ | “During handling of the above exception, another exception occurred” |
raise New() from e | __cause__ (+ __context__) | “The above exception was the direct cause of the following exception” |
raise New() from None | __suppress_context__ = True | Only the new exception |
Adding context: add_note (3.11+)
import traceback
def parse_row(n, text):
try:
return int(text)
except ValueError as e:
e.add_note(f"while parsing row {n} of users.csv")
raise
try:
parse_row(3, "abc")
except ValueError as e:
print(e.__notes__) # ['while parsing row 3 of users.csv']
print(traceback.format_exception_only(e)[-1].strip()) # while parsing row 3 of users.csvNotes are printed under the error message in tracebacks — a lightweight way to add context without wrapping the exception in a new type.
ExceptionGroup and except* (3.11+)
When several independent operations fail at once (parallel tasks, validating many fields), raise them together as an ExceptionGroup. except* handles each type inside the group; several except* clauses can run for one group.
def validate(user):
errors = []
if not user.get("name"):
errors.append(ValueError("name is required"))
if not isinstance(user.get("age"), int):
errors.append(TypeError("age must be an int"))
if "@" not in user.get("email", ""):
errors.append(ValueError("email is invalid"))
if errors:
raise ExceptionGroup("invalid user", errors)
try:
validate({"name": "", "age": "x", "email": "nope"})
except* ValueError as eg:
print("values:", [str(e) for e in eg.exceptions]) # values: ['name is required', 'email is invalid']
except* TypeError as eg:
print("types:", [str(e) for e in eg.exceptions]) # types: ['age must be an int']eg = ExceptionGroup("batch", [ValueError(1), KeyError(2), ValueError(3)])
values, rest = eg.split(ValueError)
print(len(values.exceptions), [type(e).__name__ for e in rest.exceptions]) # 2 ['KeyError']Inspecting tracebacks
import traceback
def inner():
return 1 / 0
def outer():
return inner()
try:
outer()
except ZeroDivisionError as e:
frames = traceback.extract_tb(e.__traceback__)
print([f.name for f in frames]) # ['<module>', 'outer', 'inner']
print(traceback.format_exception_only(e)[0].strip()) # ZeroDivisionError: division by zerotraceback.format_exc() returns the full traceback as a string (for logging); logger.exception("msg") does the same automatically inside an except block.
assert — for invariants, not validation
def mean(xs):
assert xs, "mean() of an empty list" # a developer's sanity check
return sum(xs) / len(xs)
try:
mean([])
except AssertionError as e:
print(e) # mean() of an empty listWarnings
A warning reports something suspicious without stopping the program — most often a deprecation. Warnings are printed once per location by default and can be filtered or turned into errors.
import warnings
def old_api():
warnings.warn("old_api() is deprecated; use new_api()", DeprecationWarning, stacklevel=2)
return 42
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = old_api()
print(result, caught[0].category.__name__) # 42 DeprecationWarning
print(caught[0].message) # old_api() is deprecated; use new_api()| Filter action | Effect |
|---|---|
"default" | Print the first occurrence per location |
"always" | Print every occurrence |
"ignore" | Never print |
"error" | Turn the warning into an exception — great in test suites (python -W error, pytest -W error) |
finally beats return
def f():
try:
return "from try"
finally:
print("finally runs first") # finally runs first
print(f()) # from tryimport warnings
# A return inside finally silently DISCARDS an in-flight exception.
# Python 3.14 warns about it at compile time (PEP 765); silenced here to show the behaviour.
src = """
def swallow():
try:
raise ValueError("lost!")
finally:
return "finally wins"
"""
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
ns = {}
exec(src, ns)
print(ns["swallow"]()) # finally winsEAFP, cost and the retry pattern
Setting up a try block is essentially free in Python 3.11+ (“zero-cost” exceptions); raising costs a few microseconds. So EAFP — just try and handle failure — is idiomatic when failure is the exception, not the rule.
def get_port(cfg):
try:
return int(cfg["port"])
except (KeyError, ValueError):
return 8080
print(get_port({"port": "5432"}), get_port({}), get_port({"port": "x"})) # 5432 8080 8080import time
def retry(fn, attempts=3, base_delay=0.001, retry_on=(ConnectionError, TimeoutError)):
for attempt in range(1, attempts + 1):
try:
return fn()
except retry_on:
if attempt == attempts:
raise # out of attempts: propagate
time.sleep(base_delay * 2 ** (attempt - 1)) # exponential backoff
calls = []
def flaky():
calls.append(1)
if len(calls) < 3:
raise ConnectionError("connection reset")
return "connected"
print(retry(flaky), len(calls)) # connected 3Interview questions
Why shouldn't you write except: or except BaseException:?
What is the difference between raise X from e and raise X from None?
What does except* do?
When would you define a custom exception?