Python Custom Exceptions: Classes, Data and Hierarchies

Define your own Python exception by subclassing Exception. Storing data as attributes, one base class per package, message style, and when a built-in is better.

  • Course: Python study plan
  • Module: Errors and exceptions
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: CPython 3.11

How do I create a custom exception in Python?

Create a custom exception in Python by subclassing Exception: class InsufficientFunds(Exception): pass is complete, and raise InsufficientFunds("balance 10, requested 50") stores the message in e.args and shows it through str(e). When handlers need details, define __init__, call super().__init__(message) and store the details as attributes. Never subclass BaseException.

Lesson

The built-in exceptions describe what kind of thing went wrong — a bad value, a missing key — but not whose code it went wrong in. A library or application that raises ValueError for everything leaves its callers unable to tell a malformed input from a failed business rule from a bug. Custom exception classes solve that: a base class for the package, subclasses for the distinct failures, fields that carry what the handler needs, and a message that reads well. This lesson covers defining them, adding data, building a small hierarchy, writing messages, and the rules for when a custom exception earns its place versus when a built-in is the honest choice.

The minimal custom exception

class InsufficientFunds(Exception):
    pass

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFunds(f"balance {balance}, requested {amount}")
    return balance - amount

try:
    withdraw(10, 50)
except InsufficientFunds as e:
    print("declined:", e)          # declined: balance 10, requested 50

Subclass Exception (never BaseException), give it a name that ends in Error or reads as a condition, and it is done: the message passed to the constructor is stored in e.args and shown by str(e). A pass body is enough — the class's identity is its content.

Carrying data

When handlers need to act on details, store them as attributes rather than making callers parse the message:

class InsufficientFunds(Exception):
    def __init__(self, balance, requested):
        super().__init__(f"balance {balance}, requested {requested}")
        self.balance = balance
        self.requested = requested

    @property
    def shortfall(self):
        return self.requested - self.balance

try:
    withdraw(acct, 50)
except InsufficientFunds as e:
    offer_overdraft(e.shortfall)

Call super().__init__ with the message so that str(e), the traceback and e.args behave normally, then set your own attributes. A dataclass-style exception (@dataclass class X(Exception)) is possible but fiddly with args; the explicit __init__ is the convention.

A hierarchy

class AppError(Exception):
    """Base for every error this application raises on purpose."""

class ConfigError(AppError):
    pass

class ValidationError(AppError):
    def __init__(self, field, message):
        super().__init__(f"{field}: {message}")
        self.field = field

class NotFound(AppError):
    pass

One base per package or application, one subclass per distinct handling. The base lets a boundary catch "anything we raised deliberately" (except AppError) separately from bugs (except Exception — a TypeError from a typo is not an AppError), and the leaves let specific code handle specific failures. Do not create a subclass for every message; create one when some caller will catch it differently from its siblings.

Multiple inheritance from a built-in is legitimate when the custom exception is a kind of built-in error too: class ConfigKeyError(ConfigError, KeyError) is caught by both except ConfigError and except KeyError, which keeps old callers working while new ones use the specific class.

Messages

The message is read by a human in a traceback or a log, so: say what was expected and what arrived (f"port must be 1–65535, got {port}"), name the thing (f"user {user_id} not found"), avoid duplicating what the class name already says, and do not end with a full stop or capitalise like a sentence — ValueError: invalid literal for int() with base 10: 'x' is the house style. Never put the fix in the message unless it is certain; put the context.

When not to define one

  • A built-in already means it. A bad argument is ValueError or TypeError; a missing key is KeyError; a missing file is FileNotFoundError. Callers already know how to handle those, and a MyValueError that nobody catches differently is noise.
  • Nobody will catch it. An exception that only ever ends the program with a traceback gains nothing from a custom class.
  • It is control flow inside one function. Use a return value or a break.

Define one when callers outside your module need to distinguish your failure from others, when handlers need structured data, or when a package wants one base to catch.

Translating at a boundary

A library that calls another library should not leak the inner one's exceptions: a caller of load_config wants ConfigError, not json.JSONDecodeError or KeyError from whatever the implementation happens to use today. The pattern is a small try at the boundary that catches the inner exception and raises the outer one from it — the cause stays visible in the traceback for debugging, but the type the caller must catch is stable across implementation changes. Do the translation once, at the edge of the module; deeper code raises whatever is natural.

Documenting and testing

A function's docstring should list what it raises (Raises ValidationError if …), because raising is part of the contract. Tests assert the class and, when it matters, the fields:

try:
    validate({"age": -1})
except ValidationError as e:
    assert e.field == "age"
else:
    raise AssertionError("expected ValidationError")

unittest's assertRaises and pytest's pytest.raises (Module 18) are the same check with less ceremony.

Pitfalls

  • Subclassing BaseException — it escapes except Exception and behaves like KeyboardInterrupt.
  • A custom __init__ that forgets super().__init__(message), so str(e) shows the raw constructor arguments — (10, 50) — instead of the message you built.
  • A class per message rather than per handling.
  • Callers parsing str(e) because the data was not stored as attributes.
  • Wrapping a built-in in a custom exception without from e, losing the cause.
  • Naming: ErrorHappened, MyException — name the condition.

Key takeaways

  • Subclass Exception, name the condition, pass a message to super().__init__; a pass body is often enough.
  • Store details handlers need as attributes; the message is for humans.
  • One base class per package, one subclass per distinct handling; inherit from a built-in too when it is a kind of that error.
  • Messages say what was expected and what arrived, in the built-ins' lower-case style.
  • Prefer a built-in when it already means the failure; define your own when callers must tell it apart or need its data.

Common questions

Should a custom exception inherit from Exception or BaseException?

From Exception. BaseException is the root that KeyboardInterrupt and SystemExit sit under, so a custom exception derived from it escapes except Exception handlers and behaves like a request to stop the program.

How do I add attributes to a custom exception in Python?

Define __init__, call super().__init__(message) first so that str(e), e.args and the traceback show the message, then set your own attributes, such as self.field = field. Handlers can then act on e.field instead of parsing the message text.

Why create a base exception class for a package?

One base, such as AppError, lets a boundary catch every error the package raises on purpose with except AppError, separately from bugs such as a TypeError. Add a subclass only when some caller will handle that failure differently from its siblings.

When should I use a built-in exception instead of a custom one?

When a built-in already means the failure: a bad argument is ValueError or TypeError, a missing key KeyError, a missing file FileNotFoundError. Define your own only when callers outside your module must tell your failure apart or need structured data from it.

How do I wrap another library's exception in my own?

Catch it at the edge of your module and raise your exception from it, as in raise ConfigError(...) from e inside except json.JSONDecodeError as e. The cause stays visible in the traceback, while callers catch one stable type however the implementation changes.

Exercises

An application hierarchy

Define AppError(Exception), ValidationError(AppError) with a field attribute and a message <field>: <problem>, and NotFound(AppError). lookup(text) raises ValidationError("id", "must be a positive integer") when the text is not a positive integer, NotFound(f"user {n} not found") when the integer exceeds 100, and otherwise returns f"user {n}". Handle each line with three handlers — ValidationError (print invalid <field>: <message>), NotFound (print missing: <message>), then AppError as a fallback that never fires here — and print the result otherwise.

Input: lines. Output: one line per input line.

7
abc
500

prints

user 7
invalid id: id: must be a positive integer
missing: user 500 not found

An exception that carries data

Define InsufficientFunds(Exception) taking balance and requested, storing both, passing the message balance <b>, requested <r> to super().__init__, and exposing a shortfall property. Process withdraw amount commands against a balance read from the first line; a successful withdrawal prints the new balance, a failed one is caught and prints declined: <message>; short by <shortfall> — read from the exception's attributes, not by parsing the message.

Input: the starting balance, then withdraw n lines. Output: one line per command.

100
withdraw 30
withdraw 100

prints

70
declined: balance 70, requested 100; short by 30

In this module: Errors and exceptions

← Exceptions — try, except, else, finally, raise · EAFP and exception-driven flow — suppress, retries, return versus raise →