Raising Custom Errors

Your own exceptions give callers a precise, catchable name for each kind of failure your code can produce. Instead of making users grep for a string in an error message, you define a class like InvalidAgeError and they write except InvalidAgeError. Custom exceptions turn vague “something went wrong” boundaries into documented, testable contracts.

A custom exception is simply a class that inherits from Exception. In its simplest form it has no body: class InvalidAgeError(Exception): pass. That one line is often enough. If the exception needs extra data (the offending value, an HTTP status code), add an __init__ that stores those fields and passes a message to super().__init__ so str(err) is still informative.

Design exceptions in a small hierarchy. Pick one root class per library or package (for example, class AppError(Exception):) and subclass it for each specific failure. That way callers can catch the whole family with one clause or pick a specific leaf, whichever fits. Keep the tree shallow: two or three levels is almost always enough.

When you raise from inside except, use raise MyError(...) from err to preserve the original cause. Tracebacks will show both exceptions with a clear “The above exception was the direct cause of the following exception” banner — invaluable when you are debugging a failure that started in a dependency.

A minimal custom exception

class InvalidAgeError(Exception): pass is a complete, useful exception. It behaves like any built-in error: you can raise it, catch it, print it, check isinstance. Callers can write except InvalidAgeError for narrow handling or except Exception for catch-all logic.

Add an __init__ only when you need structured data. class APIError(Exception): def __init__(self, status, detail): super().__init__(f"{status}: {detail}"); self.status = status; self.detail = detail keeps the message human-readable and exposes the fields for programmatic use.

Designing an exception hierarchy

Define one package-level base exception: class MyLibError(Exception): pass. Subclass it for each meaningful distinction: class NotFound(MyLibError), class Conflict(MyLibError), class AuthError(MyLibError). Users who only want to know “did something go wrong inside MyLib?” can write except MyLibError.

Avoid mirroring built-in exceptions unless it is truly appropriate. A MyValueError that subclasses ValueError is fine only if it really is a value error; if not, pick a more descriptive name and inherit from Exception directly.

The recipe for custom exceptions and the tools to raise them.

ToolPurpose
Exception
base class
Inherit from this for your own errors.
raise Err(...)
statement
Creates and raises an exception instance.
raise Err(...) from cause
syntax
Chains the original cause into the new error.
class MyErr(Exception)
pattern
Minimal custom exception definition.
err.args
attribute
Tuple of arguments; useful for structured data.
err.__cause__
attribute
The exception named after `from`.
warnings.warn
function
For recoverable issues that do not warrant an exception.
pytest.raises(Err)
context manager
Test that code raises the expected exception (pytest).

Raising Custom Errors code example

The script defines a tiny exception hierarchy for a user validation function and demonstrates narrow catches and chaining.

# Lesson: Raising Custom Errors
class UserError(Exception):
    """Base class for all user-validation errors."""


class InvalidAgeError(UserError):
    def __init__(self, value, message="age must be a non-negative integer"):
        super().__init__(f"{message}: got {value!r}")
        self.value = value


class InvalidEmailError(UserError):
    def __init__(self, value):
        super().__init__(f"invalid email address: {value!r}")
        self.value = value


def validate_user(data: dict) -> None:
    try:
        age = int(data["age"])
    except (KeyError, ValueError, TypeError) as err:
        raise InvalidAgeError(data.get("age")) from err
    if age < 0:
        raise InvalidAgeError(age)

    email = data.get("email", "")
    if "@" not in email:
        raise InvalidEmailError(email)


samples = [
    {"age": 30, "email": "a@b.com"},
    {"age": "thirty", "email": "a@b.com"},
    {"age": -1,       "email": "a@b.com"},
    {"age": 22,       "email": "not-an-email"},
]

for row in samples:
    try:
        validate_user(row)
        print("ok   :", row)
    except InvalidAgeError as err:
        print("age  :", err, "| value=", err.value)
    except InvalidEmailError as err:
        print("email:", err)
    except UserError as err:
        print("user :", err)

Read the hierarchy carefully:

1) `UserError` is the umbrella base; callers can catch it for 'any bad user input'.
2) `InvalidAgeError` carries the offending value; callers can inspect `err.value`.
3) `raise ... from err` preserves the ValueError that triggered the wrap.
4) Narrow catches let the caller react differently to age vs email problems.

Design a pair of exceptions for a tiny parser.

class ParseError(Exception):
    pass

class UnexpectedToken(ParseError):
    def __init__(self, token: str, position: int):
        super().__init__(f"unexpected {token!r} at {position}")
        self.token, self.position = token, position

def parse(seq: list[str]) -> None:
    for i, t in enumerate(seq):
        if not t.isalpha():
            raise UnexpectedToken(t, i)

try:
    parse(["a", "b", "9"])
except UnexpectedToken as err:
    print(err, "| pos=", err.position)

Enforce the contract of your custom class.

class E(Exception):
    pass
class F(E):
    pass
try:
    raise F("x")
except E as err:
    assert isinstance(err, F) and str(err) == "x"
try:
    raise F("y") from ValueError("src")
except F as err:
    assert isinstance(err.__cause__, ValueError)

Running prints something like:

ok   : {'age': 30, 'email': 'a@b.com'}
age  : age must be a non-negative integer: got 'thirty' | value= thirty
age  : age must be a non-negative integer: got -1 | value= -1
email: invalid email address: 'not-an-email'