Skip to content

Custom Errors

Python’s built-in exceptions (ValueError, TypeError, RuntimeError, …) are generic. When your code raises a ValueError, callers cannot tell whether it came from their input, a broken config file, or a network response.

Custom exception classes give your errors a distinct type that callers can catch selectively, add structured attributes (like HTTP status codes or field names), and document what can go wrong in each part of your application.

The minimal custom exception is just a class that inherits from Exception:

class AppError(Exception):
"""Base class for all application errors."""

That is enough to raise and catch it by name:

raise AppError("something went wrong")

Override __init__ to attach extra context to the exception:

class AppError(Exception):
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message) # pass message to Exception
self.code = code

Callers can then read err.code to get structured information without parsing the message string.

Create subclasses for each distinct failure category. Every NotFoundError is also an AppError, so callers can catch at whichever level of specificity they need.

class ValidationError(AppError):
def __init__(self, field: str, message: str) -> None:
super().__init__(f'Validation failed on "{field}": {message}', code=400)
self.field = field
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id: int) -> None:
super().__init__(f"{resource} id={resource_id} not found", code=404)
self.resource = resource
self.resource_id = resource_id
flowchart TD
EXC["Exception"] --> APP["AppError (code: int)"]
APP --> VAL["ValidationError (field: str)"]
APP --> NF["NotFoundError (resource: str, resource_id: int)"]
A small application error hierarchy: every leaf is also an AppError

Catching at different levels:

try:
raise ValidationError("email", "must contain @")
except ValidationError as e:
print(f"Field {e.field!r} invalid: {e}") # specific
except AppError as e:
print(f"App error [{e.code}]: {e}") # broad

A package with several modules typically defines one root exception per package (or one per subsystem) and lets every other exception in that package inherit from it — callers who only care about “did this package fail” can catch the root; callers who need per-failure detail catch the specific subclass.

except X as e: deletes e when the block ends

Section titled “except X as e: deletes e when the block ends”

CPython implicitly runs del e when an except ... as e: block exits — whether it exits normally or via another exception. This is not a style choice; it is language behavior, and it means the name is simply gone immediately afterward:

try:
raise ValueError("boom")
except ValueError as e:
print(e) # fine -- e is bound here
print(e) # NameError: name 'e' is not defined

This exists to break a reference cycle: the exception object’s traceback holds a reference to the frame, and the frame’s locals would hold a reference back to the exception, keeping both alive indefinitely without the automatic deletion. The practical effect: never plan to use the bound name after the except block — copy anything you need (the message, an attribute) to a different name first, as the demo further down does with sample instead of reusing e.

The exact error you see depends on scope: at module level, reading the deleted name raises NameError: name 'e' is not defined, as above. Inside a function, e is a local variable, and Python’s compiler already knows at compile time that the name is local to that function — reading it after the implicit del raises UnboundLocalError: cannot access local variable 'e' where it is not associated with a value instead, even though the code “looks the same” as the module-level case. Both are the same underlying deletion; only the error class differs by scope.

Every BaseException stores whatever positional arguments were passed to it in .args — super().__init__(message) is what populates self.args with (message,).

str(e) returns the message you passed to super().__init__(). This is what gets printed when you print(e) or include the exception in an f-string.

repr(e) returns ExceptionClass(message) — useful for logging, since it also identifies which exception type produced the message.

err = AppError("disk full", code=507)
str(err) # 'disk full'
repr(err) # "AppError('disk full')"
err.args # ('disk full',)
err.code # 507 -- custom attributes are NOT part of .args

Custom attributes like code live on the instance, not in .args — they are set by your own __init__, not by Exception.__init__.

Implicit chaining — __context__ vs __cause__

Section titled “Implicit chaining — __context__ vs __cause__”

Raising a new exception inside an except block always records the exception being handled, even without from — Python sets __context__ automatically. raise ... from original additionally sets __cause__, and Python’s default traceback printer changes its wording between the two (“during handling of the above exception” for implicit context vs. “the direct cause of the following exception” for an explicit cause).

try:
int("nope")
except ValueError:
raise RuntimeError("second failure") # no 'from' -- __context__ set implicitly, __cause__ is None
try:
int("nope")
except ValueError as original:
raise RuntimeError("second failure") from original # __cause__ set explicitly

raise ... from None is the only way to suppress both — it tells Python this new exception truly has nothing to do with what was being handled.

add_note() — attaching context after creation (Python 3.11+)

Section titled “add_note() — attaching context after creation (Python 3.11+)”

BaseException.add_note(note) appends a string to the exception’s __notes__ list, which the default traceback printer includes automatically. It is useful for attaching context you only learn about after the exception object already exists — for example, while it is propagating through a retry loop:

err = AppError("upstream timeout", code=504)
err.add_note("retry attempt 1 of 3")
err.add_note("upstream host: api.internal:8443")
raise err

Unlike building a longer message string up front, notes are added incrementally and appear as separate lines in the traceback, keeping the original message intact for anything that parses or compares it.

When to subclass a builtin vs. define your own base

Section titled “When to subclass a builtin vs. define your own base”

Subclassing a builtin (class NegativeAmountError(ValueError):) means every existing caller that already catches ValueError keeps working unchanged, while callers who need your extra attributes can catch the specific subclass. Defining your own base (class AppError(Exception):) makes sense when the failure is specific to your application domain and there is no builtin that already means the right thing — forcing every caller, old and new, through your hierarchy on purpose.

class NegativeAmountError(ValueError):
"""A ValueError with extra context -- callers who only know ValueError still catch it."""
def __init__(self, amount: float) -> None:
super().__init__(f"amount must be non-negative, got {amount}")
self.amount = amount

A generic except ValueError: still catches NegativeAmountError unchanged; code that specifically needs .amount catches NegativeAmountError directly. Prefer subclassing a builtin whenever your error genuinely is a more specific case of an existing one; reach for a fresh application-specific base only when it is not.

# --- base application error ---
class AppError(Exception):
"""Base class for all application errors."""
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
# --- specific subclasses ---
class ValidationError(AppError):
"""Raised when input validation fails."""
def __init__(self, field: str, message: str) -> None:
super().__init__(
f'Validation failed on field "{field}": {message}',
code=400,
)
self.field = field
class NotFoundError(AppError):
"""Raised when a requested resource does not exist."""
def __init__(self, resource: str, resource_id: int) -> None:
super().__init__(
f"{resource} with id={resource_id} not found",
code=404,
)
self.resource = resource
self.resource_id = resource_id
# --- catching at different levels ---
errors = [
ValidationError("email", "must contain @"),
NotFoundError("User", 42),
AppError("generic app failure", code=500),
]
for err in errors:
try:
raise err
except ValidationError as e:
print(f"[{e.code}] Validation on {e.field!r}: {e}")
except NotFoundError as e:
print(f"[{e.code}] {e.resource} not found (id={e.resource_id})")
except AppError as e:
print(f"[{e.code}] AppError: {e}")
# --- isinstance hierarchy ---
# 'e' is deleted when the except block ends (CPython) — use a new name here
print()
sample = ValidationError("name", "too short")
print(f"ValidationError? {isinstance(sample, ValidationError)}")
print(f"AppError? {isinstance(sample, AppError)}")
print(f"Exception? {isinstance(sample, Exception)}")
print(f"BaseException? {isinstance(sample, BaseException)}")
# --- except X as e: deletes e when the block ends ---
print("=== except ... as e deletes e ===")
try:
raise ValueError("boom")
except ValueError as e:
print(f" inside the except block: {e}")
print(" now outside the except block:")
try:
print(e) # NameError: 'e' was deleted by an implicit del e when the block ended
except NameError as ne:
print(f" NameError: {ne}")
# --- args and repr ---
print("=== args and repr ===")
class AppError(Exception):
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
err = AppError("disk full", code=507)
print(f" str(err) = {str(err)!r}")
print(f" repr(err) = {repr(err)!r}")
print(f" err.args = {err.args}")
print(f" err.code = {err.code}")
# --- implicit chaining: __context__ vs __cause__ ---
print("=== implicit chaining (__context__) vs explicit (__cause__) ===")
try:
try:
int("nope")
except ValueError:
raise RuntimeError("second failure") # no 'from' -- __context__ set implicitly
except RuntimeError as e:
print(f" no 'from': __cause__={e.__cause__!r} __context__={e.__context__!r}")
try:
try:
int("nope")
except ValueError as original:
raise RuntimeError("second failure") from original # __cause__ set explicitly
except RuntimeError as e:
print(f" with 'from': __cause__={e.__cause__!r}")
# --- add_note() (3.11+) ---
print("=== add_note() ===")
try:
err2 = AppError("upstream timeout", code=504)
err2.add_note("retry attempt 1 of 3")
err2.add_note("upstream host: api.internal:8443")
raise err2
except AppError as e:
print(f" {e}")
print(f" notes: {e.__notes__}")
# --- when to subclass a builtin vs your own base ---
print("=== builtin subclass vs custom base ===")
class NegativeAmountError(ValueError):
"""A ValueError with extra context -- callers who only know ValueError still catch it."""
def __init__(self, amount: float) -> None:
super().__init__(f"amount must be non-negative, got {amount}")
self.amount = amount
def withdraw(amount: float) -> float:
if amount < 0:
raise NegativeAmountError(amount)
return amount
try:
withdraw(-10.0)
except ValueError as e: # generic callers that only know about ValueError still catch this
print(f" caught as plain ValueError: {e}")
try:
withdraw(-10.0)
except NegativeAmountError as e: # callers who need the extra .amount attribute catch the specific type
print(f" caught as NegativeAmountError, amount={e.amount}")

An exception class is an ordinary Python class, so everything you know about classes applies: __init__ sets up attributes, isinstance walks the MRO, and inheritance means “catchable as.” The one piece of magic specific to except ... as e: is CPython’s automatic del e at block exit — that single implicit statement is responsible for both the reference-cycle-breaking behavior and the NameError surprise above.

What you getBenefitCost
Custom exception hierarchyCallers catch at exactly the right specificityMore classes to define and keep organized
Subclassing a builtin (ValueError)Existing generic catchers keep workingLess explicit than a dedicated application base
Custom attributes (.code, .field)Structured data without parsing the messageExtra __init__ boilerplate per exception class
add_note()Attach context discovered after creation, without rebuilding the messageNotes are appended text, not structured data — still just a string
  • “I can keep using e after the except block, like any other variable.” CPython deletes it automatically when the block ends, even on success — using it afterward raises NameError.
  • “Custom attributes like .code are stored in .args.” They are not — .args holds only what was passed to Exception.__init__ (or BaseException.__init__). Custom attributes are separate instance attributes you set yourself.
  • “Raising a new exception without from discards the original.” It does not — Python still records it in __context__ automatically; only raise ... from None truly discards it.
  • “Every custom error needs its own brand-new base class.” If the failure genuinely is a more specific ValueError (or KeyError, TypeError, …), subclass the builtin — existing generic handlers keep working for free.

Before trusting a generated custom-exception hierarchy, check for these:

  • Does any code read the except ... as e: binding after the block has ended? It will raise NameError the moment that path actually runs.
  • Are custom attributes (like .code or .field) actually set as instance attributes, or is the code trying to pull them out of .args?
  • Does the hierarchy subclass BaseException directly instead of Exception? That risks accidentally catching KeyboardInterrupt or SystemExit wherever the new base is caught broadly.
  • Is a brand-new exception class defined for something that is really just a ValueError or KeyError in disguise? Check whether subclassing the builtin would let existing callers keep working.
  • Does re-raising inside an except block ever need from None, but the generated code leaves the implicit __context__ chain in place, printing a confusing double traceback to end users?

🐛 Spot the bug

def parse_amount(raw: str) -> float:
try:
return float(raw)
except ValueError as e:
log_message = f"failed to parse {raw!r}: {e}"
print(log_message)
print(e) # meant to log the original exception object too
return 0.0
parse_amount("not-a-number")
Show the answer

e is only bound while the except ValueError as e: block is executing. CPython runs an implicit del e the moment that block ends, whether it finished normally or not. Because e is a local variable inside parse_amount, the print(e) two lines later raises UnboundLocalError: cannot access local variable 'e' where it is not associated with a value — not NameError (that would only happen for a module-level e) — even though log_message (built inside the block) is still perfectly readable. The fix is to capture anything needed later — str(e), e.args, or the exception object itself under a different name — before the block ends.

Why should custom application exceptions subclass Exception instead of BaseException?
Given a hierarchy NotFoundError -> AppError -> Exception, which except clause would catch a NotFoundError?
What does calling super().__init__(message) inside a custom exception's __init__ do?
You have except ValidationError before except AppError in a try block. A ValidationError is raised. Which clause runs?
What happens on the last line?
try:
    raise ValueError("boom")
except ValueError as e:
    msg = str(e)
print(e)
What is __context__ on the raised RuntimeError, given there is no from clause?
try:
    int("x")
except ValueError:
    raise RuntimeError("second")
Your application raises NegativeAmountError, defined as class NegativeAmountError(ValueError): .... What is the main benefit of this choice over class NegativeAmountError(AppError): ... (a fresh application base)?