Exception Hierarchy and Custom Errors
Exceptions in Python form a hierarchy rooted at BaseException, and custom errors are classes in that hierarchy - the same OOP machinery (inheritance, __init__, attributes) applied to failure. Designing the hierarchy well is a design skill: a library's exception types are part of its public API, because callers except on them, and the shape of the hierarchy decides what a caller can catch precisely and what it must over-catch. This chapter walks the built-in tree, shows how to define custom exceptions with the right base and the right __init__, and gives the rules for raising, catching, and chaining them.
The Built-in Hierarchy
# The shape that matters (abbreviated):
# BaseException
# +-- SystemExit, KeyboardInterrupt, GeneratorExit
# +-- Exception
# +-- ValueError, TypeError, KeyError, ...
# +-- OSError (IOError is an alias)
# | +-- FileNotFoundError, PermissionError, ...
# +-- ArithmeticError
# | +-- ZeroDivisionError, OverflowError
print(issubclass(FileNotFoundError, OSError)) # True
print(issubclass(ZeroDivisionError, ArithmeticError)) # True
print(issubclass(KeyboardInterrupt, Exception)) # False - it is a BaseException
print(issubclass(ValueError, Exception)) # True
The practical read: Exception is the root you almost always catch (it excludes KeyboardInterrupt and SystemExit, which you should not swallow by accident). Below it, the tree is organized by cause - ValueError for a right-type-but-wrong-value argument, TypeError for a wrong type, KeyError for a missing key, the OSError family for the OS - and each family has specific subclasses (FileNotFoundError under OSError) that let a caller catch the exact failure. Choosing the right built-in for a raise is the first custom-error skill: if a built-in names your failure precisely, use it; invent a class only when the failure is domain-specific and a caller should distinguish it from the generic case.
Defining Custom Exceptions
class AppError(Exception):
"""Base for all errors this module raises."""
class ValidationError(AppError):
def __init__(self, field: str, value: object, reason: str):
self.field = field
self.value = value
self.reason = reason
# build the message the base class stores in self.args
super().__init__(f"{field}={value!r}: {reason}")
class NotFoundError(AppError):
def __init__(self, entity: str, key: object):
self.entity = entity
self.key = key
super().__init__(f"{entity} {key!r} not found")
# raising with structured context
def set_age(user: dict, age: int) -> None:
if not isinstance(age, int):
raise TypeError("age must be an int") # right built-in
if not 0 <= age <= 130:
raise ValidationError("age", age, "out of plausible range")
try:
set_age({}, 200)
except ValidationError as e:
print(e.field, e.value, e.reason) # age 200 out of plausible range
print(e) # the message from super().__init__
The pattern: a base AppError(Exception) that names the module, and specific subclasses per failure. Each __init__ stores the structured context as attributes (field, value) and calls super().__init__(message) so the human-readable message lives in self.args where print(e) and the traceback find it. The two jobs are separate and both required - the attributes are for programmatic handling (a caller that logs e.field), the message is for the human reading the trace. Do not choose between them; the super().__init__ call is the part most custom exceptions forget, and its absence is why some errors print as <ValidationError object at 0x...> instead of a message.
Raising: The Right Type and the Right Context
def divide(a: float, b: float) -> float:
if b == 0:
# let the specific built-in do the work, with context chained
raise ZeroDivisionError("division by zero in ratio()")
return a / b
def load_config(path: str) -> dict:
try:
with open(path) as f:
return dict(line.split("=") for line in f if "=" in line)
except FileNotFoundError as e:
# re-raise a domain error, CHAINING the cause
raise NotFoundError("config", path) from e
try:
load_config("/missing/conf.ini")
except NotFoundError as e:
print(e) # config '/missing/conf.ini' not found
print(e.__cause__) # the FileNotFoundError - the chain is intact
Two raising rules. First, raise the most specific type you can name - a FileNotFoundError beats a bare OSError, a ValidationError("age", ...) beats a generic AppError; the specificity is what lets a caller catch exactly the failure it can handle. Second, when you translate one exception into another (a low-level FileNotFoundError into a domain NotFoundError), use raise New(...) from original to chain the cause - the During handling of the above exception, another exception occurred in the traceback, and e.__cause__ for programmatic access. The from clause is the difference between a readable trace ("config not found, because the file was missing") and a mystery ("config not found" with the real cause hidden). A bare raise inside an except re-raises the current exception with its original traceback intact - the idiomatic "I checked, I cannot handle it, I pass it on" move.
Catching: Precise, and in the Right Order
def process(item) -> None:
# catch the specific types you can actually recover from
try:
handle(item)
except ValidationError as e:
log(f"skipping {e.field}: {e.reason}")
return
except (TypeError, ValueError) as e:
log(f"bad input shape: {e}")
return
# no bare except: let unexpected errors (and KeyboardInterrupt) propagate
# order matters: the most specific first
try:
risky()
except FileNotFoundError:
recover_file()
except OSError:
recover_os() # catches the rest of the OSError family
Catch the specific types you can handle, in order from most to least specific (a FileNotFoundError handler before the OSError handler that would otherwise shadow it). A bare except: catches KeyboardInterrupt and SystemExit - almost always a bug, because it swallows the user's Ctrl-C and the interpreter's shutdown; except Exception: is the widest you should ever go, and even that should be at a top-level boundary with a log, not deep in logic. The structure to internalize: each except clause names a failure mode the code knows how to answer, and anything it does not recognize propagates to a caller that might. Swallowing everything is how a real failure becomes a silent wrong result three frames up.
The Working Rules
- Use the built-in when it names the failure.
ValueError,TypeError,KeyError, theOSErrorfamily - inventing a parallel type for a generic failure fragments the hierarchy callers already know. - Custom classes extend
Exceptionthrough a module base.class MyError(Exception)at minimum; a module-level base with specific children for a public API. - Always call
super().__init__(message)so the message is inargs; store structured context as attributes for programmatic handlers. - Chain with
fromwhen translating; re-raise bare when passing through; neverexcept:without a reason. - Exceptions are for exceptions. A branch that is an expected control-flow case (a missing cache entry, an end-of-input) is a return value or a
None, not a raise - raising to control flow hides the normal path from the reader.
Summary
The exception tree is OOP: BaseException at the root, Exception as the catch-all root, and cause-organized families below. Define custom errors as subclasses of a module base extending Exception, give each an __init__ that stores structured context as attributes and calls super().__init__(message) for the human-readable string. Raise the most specific type you can name, chain translations with from, re-raise bare when passing through, and catch specific types in most-to-least-specific order - never a bare except: that swallows KeyboardInterrupt. The hierarchy is the API: its shape decides how precisely a caller can distinguish your failures, so design it the way you would design the class interface, because it is one.