Skip to content
  • Exception Hierarchy & Advanced Handling · Python

Exception Hierarchy & Advanced Handling

LearnViaNotes @learnvianotes· 8 min read

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

Built-in exception hierarchy (abridged)
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…
Python
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']
Python
import sys
try:
    sys.exit(2)                    # sys.exit just raises SystemExit
except SystemExit as e:
    print("exit code", e.code)     # exit code 2

Designing 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.

Python
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) | limit

Catching several types & re-raising

Python
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']
Python
try:
    1 / 0
except ZeroDivisionError as e:
    saved = e
print("e" in dir(), type(saved).__name__)    # False ZeroDivisionError

Chaining: __context__ vs __cause__

Python
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 writeAttribute setTraceback 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__ = TrueOnly the new exception

Adding context: add_note (3.11+)

Python
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.csv

Notes 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.

Python
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']
Python
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

Python
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 zero

traceback.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

Python
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 list

Warnings

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.

Python
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 actionEffect
"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

Python
def f():
    try:
        return "from try"
    finally:
        print("finally runs first")   # finally runs first

print(f())                            # from try
Python
import 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 wins

EAFP, 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.

Python
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 8080
Python — retry with exponential backoff
import 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 3

Interview 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?

Free for students and teachers

Write notes like this one

Cards for every idea, quizzes and flashcards, read aloud — on the web and your phone.

Something wrong with this note? Open it to report