Custom Errors
Why custom exceptions matter
Section titled “Why custom exceptions matter”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.
Subclassing Exception
Section titled “Subclassing Exception”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")Adding custom attributes
Section titled “Adding custom attributes”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 = codeCallers can then read err.code to get structured information without parsing the message string.
Building a hierarchy
Section titled “Building a hierarchy”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_idflowchart TD EXC["Exception"] --> APP["AppError (code: int)"] APP --> VAL["ValidationError (field: str)"] APP --> NF["NotFoundError (resource: str, resource_id: int)"]
Catching at different levels:
try: raise ValidationError("email", "must contain @")except ValidationError as e: print(f"Field {e.field!r} invalid: {e}") # specificexcept AppError as e: print(f"App error [{e.code}]: {e}") # broadA 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 hereprint(e) # NameError: name 'e' is not definedThis 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.
args, str(), and repr()
Section titled “args, str(), and repr()”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 .argsCustom 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 explicitlyraise ... 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 errUnlike 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 = amountA 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.
Full runnable demo
Section titled “Full runnable demo”# --- 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 hereprint()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)}")Loading Python runtime (first run only)…
Depth demo: everything above, running
Section titled “Depth demo: everything above, running”# --- 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 endedexcept 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 implicitlyexcept 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 explicitlyexcept 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 err2except 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}")Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Custom exception hierarchy | Callers catch at exactly the right specificity | More classes to define and keep organized |
Subclassing a builtin (ValueError) | Existing generic catchers keep working | Less explicit than a dedicated application base |
Custom attributes (.code, .field) | Structured data without parsing the message | Extra __init__ boilerplate per exception class |
add_note() | Attach context discovered after creation, without rebuilding the message | Notes are appended text, not structured data — still just a string |
Common Misconceptions
Section titled “Common Misconceptions”- “I can keep using
eafter theexceptblock, like any other variable.” CPython deletes it automatically when the block ends, even on success — using it afterward raisesNameError. - “Custom attributes like
.codeare stored in.args.” They are not —.argsholds only what was passed toException.__init__(orBaseException.__init__). Custom attributes are separate instance attributes you set yourself. - “Raising a new exception without
fromdiscards the original.” It does not — Python still records it in__context__automatically; onlyraise ... from Nonetruly discards it. - “Every custom error needs its own brand-new base class.” If the failure genuinely is a more specific
ValueError(orKeyError,TypeError, …), subclass the builtin — existing generic handlers keep working for free.
Reading AI-generated code
Section titled “Reading AI-generated code”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 raiseNameErrorthe moment that path actually runs. - Are custom attributes (like
.codeor.field) actually set as instance attributes, or is the code trying to pull them out of.args? - Does the hierarchy subclass
BaseExceptiondirectly instead ofException? That risks accidentally catchingKeyboardInterruptorSystemExitwherever the new base is caught broadly. - Is a brand-new exception class defined for something that is really just a
ValueErrororKeyErrorin disguise? Check whether subclassing the builtin would let existing callers keep working. - Does re-raising inside an
exceptblock ever needfrom 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.