Custom Exceptions & Error Design
Well-designed custom exceptions form a small hierarchy under a common base, carry structured context, and are raised and chained so callers can catch precisely what they mean to handle.
Learning objectives
- › Design a custom exception hierarchy with a common base class
- › Decide when to subclass a built-in (ValueError, etc.) versus a custom base
- › Chain exceptions with `raise ... from ...` and read __cause__ vs __context__
- › Catch narrowly, add structured context, and avoid swallowing errors
Prerequisites
Version notes
- · Exception chaining with `raise ... from ...` has existed since Python 3.0; the implicit `__context__` is set automatically when a new exception is raised inside an `except` block.
- · Exception groups (`ExceptionGroup`) and the `except*` syntax were added in Python 3.11 (PEP 654).
- · `BaseException.add_note()`, for attaching extra strings to an exception's traceback, arrived in Python 3.11 (PEP 678).
Simple explanation
What it is. A custom exception is a class you define (normally subclassing `Exception`) to represent a specific failure in your domain. Good error design groups these under a shared base so related failures can be caught together, and attaches structured data so callers can react programmatically rather than parse message strings.
Why it exists. Built-in exceptions like `ValueError` are generic — many unrelated things raise them, so catching one is imprecise and risks swallowing a bug from elsewhere. A named hierarchy (AppError -> OrderError -> PaymentDeclined) lets callers catch exactly the layer they can handle and lets everything else propagate.
The problem it solves. It turns error handling from guesswork into a contract: callers know which exceptions a module raises, can catch them narrowly, and can inspect attributes (an error code, the offending value, a retry hint) instead of scraping the message text.
How it works. Define one base exception for your package, then subclass it for each meaningful failure. Add `__init__` parameters for structured context. When you catch a low-level error and re-raise a domain one, use `raise DomainError(...) from original` to preserve the cause. Catch specific types, use `else` for the success-only path, and `finally` for cleanup that must always run.
Analogy: Think of an emergency dispatch system. A single generic hotline (a bare `except`) forces one responder to handle everything. A well-designed system routes 'fire', 'medical', and 'police' to specialists — each caller handles the category it is equipped for, and anything unrecognised still escalates up the chain.
Common misconceptions
- "Custom exceptions need a big class body." — Usually `class OrderError(AppError): pass` is enough; add a custom `__init__` only when you need to carry structured data.
- "`raise X from Y` and a plain `raise X` inside an except are the same." — Both link the errors, but `from` sets `__cause__` ('the direct cause'), while an implicit raise sets `__context__` ('during handling of the above'). Use `from None` to suppress the context.
- "Catching `Exception` broadly is a fine default." — A wide catch that logs-and-continues hides bugs like KeyError and TypeError; a truly bare `except:` even traps Ctrl-C and interpreter exit. Catch the narrowest type you can actually handle.
- "Exceptions are only messages you print." — They carry state; attach attributes (codes, IDs, values) and let callers branch on them, not on the human-readable string.
Common alternatives
- Result/Either return types (or a (value, error) tuple) for expected, non-exceptional outcomes
- Sentinel return values like None when absence is normal rather than an error
- Validation libraries (e.g. pydantic) that aggregate many field errors before raising once
Code examples
Note: One base (AppError) lets callers catch the whole family; subclasses give fine-grained handling.
Behind the scenes
- ▸Every exception ultimately derives from `BaseException`; user code should subclass `Exception` (not `BaseException`) so it is not caught by handlers meant for `KeyboardInterrupt`/`SystemExit`.
- ▸`except SomeError as e` binds `e` only inside the block; Python deletes the name on exit (to break a reference cycle with the traceback), so copy out anything you still need.
- ▸`raise B from A` sets `B.__cause__ = A` and `B.__suppress_context__ = True`; a plain `raise B` inside an `except` sets `B.__context__` to the active exception automatically.
- ▸Each exception carries a `__traceback__`; a bare `raise` re-raises it unchanged, while raising a fresh statement attaches a new traceback frame.
- ▸`args` holds the constructor arguments and is what `str(exc)`/`repr(exc)` render — which is why a custom `__init__` should still call `super().__init__(message)`.
Interview insights
Key definitions
- · Custom exception: a user-defined class (subclassing Exception) representing a specific, catchable failure mode in your domain.
- · Exception chaining: linking a raised exception to the one that caused it, via __cause__ (explicit `from`) or __context__ (implicit, during handling).
How would you design an exception hierarchy for a library, and why not just raise ValueError everywhere?
Concise answer: Define one base exception per package (e.g. AppError(Exception)) and subclass it for each meaningful failure. Callers can then catch your errors as a group or individually, and they stay distinct from unrelated ValueErrors raised elsewhere.
Detailed answer: A single base means a caller can do `except AppError` to catch anything from your library while letting truly unexpected errors propagate. Subclasses (OrderError, PaymentDeclined) give fine-grained handling. Subclass a built-in only when your error genuinely *is* that kind (a stricter ValueError, say) so existing `except ValueError` handlers still work. Add structured attributes — codes, offending values — so callers branch on data instead of message strings.
Common mistake: Raising bare Exception or ValueError everywhere, forcing callers to catch overly broad types or parse messages.
Follow-ups:
- · When would you subclass ValueError instead of your own base?
- · How do you attach structured context to an exception?
What is the difference between `raise X from Y`, a bare `raise` in an except, and `raise X` in an except?
Concise answer: `raise X from Y` sets X.__cause__ explicitly (the direct cause). A bare `raise` re-raises the current exception unchanged, preserving its traceback. `raise X` inside an except sets X.__context__ implicitly ('during handling of the above').
Detailed answer: Chaining preserves the story of what went wrong. Use `from` when you deliberately translate one error into another (low-level -> domain). A bare `raise` is for re-raising after inspecting or logging, without altering the traceback. If you raise a new exception inside an except without `from`, Python still links the original as __context__ so it shows in the traceback; use `from None` to suppress that when the original is just noise.
Common mistake: Losing the original cause by catching and raising a fresh exception without `from`, or hiding useful context with `from None`.
Follow-ups:
- · What does `from None` do?
- · Why avoid a bare `except:`?
Why is a bare `except:` that logs and continues dangerous, and when is 'log and continue' actually fine?
Concise answer: A bare except catches BaseException — including KeyboardInterrupt and SystemExit — and masks bugs like KeyError/TypeError, so real failures pass silently and the program limps on in a bad state.
Detailed answer: Catch the narrowest type you can actually recover from and let the rest propagate to a top-level handler that logs with a full traceback. 'Log and continue' is only safe at genuine boundaries — a request handler, a message-loop worker, a batch item — where skipping one unit and carrying on is the intended behaviour, and even there you should catch `Exception`, not a bare `except:`.
Common mistake: Using try/except as a catch-all to keep code from crashing, which converts loud failures into silent data corruption.
Follow-ups:
- · Where is 'log and continue' appropriate?
- · What is the difference between logging and raising?
Knowledge check
What does this print?
class AppError(Exception): pass
class OrderError(AppError): pass
try:
raise OrderError("x")
except AppError:
print("caught as AppError")
except OrderError:
print("caught as OrderError")After `raise ValueError('b') from original_error`, which attribute holds original_error?
What is the biggest problem with a bare `except:` around a block of business logic?
Score: 0/3