Python custom exceptions, explained
Python ships with a rich tree of built-in exceptions, and you can extend it with your own. Understanding the hierarchy tells you what to catch and what to subclass; designing good custom exceptions makes your library's failures easy for callers to handle precisely.
The built-in hierarchy
Every exception descends from BaseException. Almost all normal errors descend from
Exception — the level you should catch and subclass. A few system-level signals sit
beside it, deliberately outside Exception.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError, TypeError, KeyError, IndexError, ...
├── ArithmeticError → ZeroDivisionError
└── OSError → FileNotFoundError, PermissionError, ...
This is why you catch Exception, not BaseException: catching the latter would also
swallow KeyboardInterrupt (Ctrl-C) and SystemExit, which you almost never want.
Defining a custom exception
The minimum is a class that inherits from Exception. Often that empty body is enough — the
name carries the meaning.
class ValidationError(Exception):
"""Raised when input fails validation."""
raise ValidationError("email is required")
Inheriting from Exception gives you message storage, str(), and args for free. Avoid
subclassing BaseException directly — your errors should be catchable by ordinary
except Exception handlers.
A base class per package
A widely-used pattern: give your library one base exception, then derive specific ones from it. Callers can catch the base to handle anything from your library, or a specific subclass for fine control.
class PaymentError(Exception):
"""Base for all payment-related errors."""
class CardDeclined(PaymentError):
pass
class InsufficientFunds(PaymentError):
pass
# Caller chooses the granularity:
try:
charge(card)
except InsufficientFunds:
top_up()
except PaymentError: # catches any other payment failure
log_and_alert()
This is the structure used by libraries like requests (RequestException base) — it makes
your error surface predictable.
Adding useful attributes
Exceptions are just objects, so attach data callers need to react. Override __init__, but
remember to call super().__init__ so the message still works.
class APIError(Exception):
def __init__(self, message, *, status_code, response=None):
super().__init__(message) # sets args/str
self.status_code = status_code
self.response = response
try:
call_api()
except APIError as e:
if e.status_code == 429:
backoff_and_retry()
Structured attributes beat forcing callers to parse the message string.
Raising and re-raising well
Raise the most specific type you have, and when translating a lower-level error, chain it so the original cause survives in the traceback.
def load_user(uid):
try:
row = db.fetch(uid)
except db.DBError as e:
raise UserNotFound(f"no user {uid}") from e # preserves the cause
if row is None:
raise UserNotFound(f"no user {uid}")
raise from keeps debugging information; swallowing the cause throws it away.
When NOT to create a new exception
Don't invent a type when a built-in already says it precisely. Bad arguments → ValueError
or TypeError; missing key → KeyError; unsupported operation → NotImplementedError.
Custom exceptions earn their keep when callers need to catch your specific failure or
when the error is a domain concept the standard library has no name for.
Recap
All exceptions descend from BaseException, but you catch and subclass Exception so
you don't swallow KeyboardInterrupt/SystemExit. Define custom errors by subclassing
Exception — often just for the name — and give a library one base class with specific
subclasses so callers pick their granularity. Attach structured attributes via a
super().__init__-calling constructor, chain translated errors with raise ... from, and
prefer a precise built-in (ValueError, KeyError) when one already fits.