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.
| Tool | Purpose |
|---|---|
Exceptionbase class | Inherit from this for your own errors. |
raise Err(...)statement | Creates and raises an exception instance. |
raise Err(...) from causesyntax | Chains the original cause into the new error. |
class MyErr(Exception)pattern | Minimal custom exception definition. |
err.argsattribute | Tuple of arguments; useful for structured data. |
err.__cause__attribute | The exception named after `from`. |
warnings.warnfunction | 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'