PYTHON / ERRORS AND EXCEPTIONS
Custom exception classes
Design your own exception types with a shared base class and attached data, so callers can catch failures precisely instead of parsing message strings.
What you will learn
- Define a module-level base error subclassing Exception, then specialise below it
- Attach structured attributes (key, value, retry_after) to the exception instance
- Pass a human-readable message to super().__init__ so str(exc) and args work
- Order except clauses from most specific to most general, since subclasses match the base
Understanding Custom exception classes
A custom exception class is part of your public API, not decoration. The class identity is the machine-readable part of a failure: callers write `except MissingKeyError` and the interpreter matches it with isinstance, so the type is what code branches on. The message string is for humans reading a traceback, which is why extracting information by slicing str(exc) is fragile and matching on the class is not.
The usual shape is one base class per module or library, inheriting from Exception, with narrower subclasses under it. That hierarchy gives callers a choice of granularity for free: `except ConfigError` catches everything your module can raise, including subclasses you add in a later version, while `except MissingKeyError` reacts to one situation. Inherit from Exception rather than BaseException, because BaseException sits outside the reach of `except Exception` handlers, a place reserved for KeyboardInterrupt and SystemExit that deliberately should not be swallowed.
When the failure carries data, put that data on the instance instead of only in the text. Override __init__, store the values as attributes, and pass a formatted message up with super().__init__ so BaseException fills in self.args. Skipping the super() call is what produces an exception whose traceback shows only the class name and which breaks copy and pickle, because BaseException reconstructs instances by calling the class with self.args.
class ConfigError(Exception):
"""Base class for every configuration problem this module raises."""
class MissingKeyError(ConfigError):
def __init__(self, key):
super().__init__(f"missing required key: {key!r}")
self.key = key
class BadValueError(ConfigError):
def __init__(self, key, value, expected):
super().__init__(f"{key!r} expected {expected}, got {value!r}")
self.key = key
self.value = value
def read_port(config):
if "port" not in config:
raise MissingKeyError("port")
port = config["port"]
if not isinstance(port, int):
raise BadValueError("port", port, "int")
return port
for config in ({"port": 8080}, {}, {"port": "8080"}):
try:
print("port =", read_port(config))
except ConfigError as exc:
print(f"{type(exc).__name__}: {exc}")
print(" key was:", exc.key)An exception class is a typed, catchable contract: the class says what went wrong, its attributes carry the details, and the inheritance tree decides how coarsely callers can catch it.
Worked examples
Attributes, args, and why super() matters
Shows what BaseException does with the arguments you forward, and how that keeps the instance copyable.
import copy
class TransientError(Exception):
pass
class RateLimited(TransientError):
def __init__(self, retry_after):
super().__init__(retry_after)
self.retry_after = retry_after
def __str__(self):
return f"retry after {self.retry_after}s"
err = RateLimited(30)
print(str(err))
print(repr(err))
print(err.args)
print(isinstance(err, TransientError), isinstance(err, Exception))
print(copy.copy(err).retry_after)Example explained
Line 1super().__init__(retry_after) stores the value in self.args, which is where repr() reads from.
Line 2__str__ controls the one line a traceback prints, so it can be friendlier than the raw args tuple.
Line 3isinstance is True for both classes, which is exactly what `except TransientError` uses to match.
Line 4copy.copy works because BaseException rebuilds the object as RateLimited(*args); an empty args tuple here would raise TypeError.
Important notes
Subclass a builtin such as ValueError or KeyError only when the semantics genuinely match, since existing `except ValueError` code will then catch your error whether or not you intended it.
A docstring is a complete class body, so `class ConfigError(Exception): """..."""` needs no `pass`; keep real logic out of exception classes.
Common mistakes
Inheriting from BaseException instead of Exception, so the error slips past every `except Exception` handler and top-level logging never sees it.
Overriding __init__ with extra parameters but never calling super().__init__, leaving args empty; the traceback shows only the bare class name and copy or pickle raises TypeError.
Defining unrelated flat exceptions with no shared base, forcing callers to name every one; adding a new error class then silently breaks their handling.
Try it yourself
Change, predict, then run
Write `BankError(Exception)` and `InsufficientFunds(BankError)` that stores `balance` and `amount`, then call a withdraw function that raises it and print the shortfall (`exc.amount - exc.balance`) from the handler.
Open the Python workspaceCheck your understanding
A library defines `class ParseError(Exception)` and `class UnexpectedToken(ParseError)`. A caller writes `except ParseError:` first, then `except UnexpectedToken:` below it. What happens when UnexpectedToken is raised?
- The ParseError block runs and the UnexpectedToken block is unreachable, because clauses are tested top to bottom and a subclass matches its base
- Python picks the most specific matching clause, so the UnexpectedToken block runs
- Both blocks run, in the order they are written
- Python raises a TypeError about overlapping except clauses when the function is defined
Show answer
except clauses are checked in written order using an isinstance test, and UnexpectedToken is an instance of ParseError, so the first clause wins and the second is dead code. Python never reorders clauses to prefer the most specific match, which is why specific classes must be listed before their base.