Custom Exceptions & Exception Chaining
Reviewed & published by Brayan K
A custom exception is your own class that subclasses Exception, giving a failure a meaningful name callers can catch precisely — and exception chaining (raise ... from ...) links a high-level error to the lower-level cause that triggered it.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Robust programs don't just raise errors — they raise the right errors, organized into a hierarchy, with the original cause preserved. That's the difference between a cryptic stack trace and an error message that tells you exactly what went wrong and why.
What You'll Learn in This Lesson
- Subclassing Exception to name your errors
- Designing a custom exception hierarchy
- Chaining errors with raise ... from ... (__cause__)
- Handling multiple errors with ExceptionGroup
- Using else and finally correctly
- Re-raising — and when to catch at all
1 Custom Exceptions & Hierarchies
Subclass Exception to create a named error. Group related errors under a common base so callers can catch the whole family at once or pick out a specific one:
class AppError(Exception):
"""Base for everything this app raises."""
class ValidationError(AppError):
"""A value failed a rule."""
class ConfigError(AppError):
"""Something is wrong with configuration."""
def validate_age(age):
if age < 0:
raise ValidationError(f"age cannot be negative: {age}")
return age
try:
validate_age(-5)
except AppError as e: # catches the whole family
print(f"{type(e).__name__}: {e}")
# ValidationError: age cannot be negative: -5📐 A small exception tree:
Exception
└─ AppError <- catch this to get ALL app errors
├─ ValidationError
└─ ConfigErrorCatching AppError grabs both subtypes; catching ValidationError grabs only that one. The hierarchy gives callers a choice of precision.
🔗 Chaining with raise ... from ...
When a low-level error happens, you often want to raise a clearer, higher-level one — without losing the original. raise NewError(...) from original does exactly that, storing the original in __cause__:
class ConfigError(Exception):
pass
def load_port(raw):
try:
return int(raw)
except ValueError as e:
# translate a low-level ValueError into a domain error,
# but keep the original as the cause
raise ConfigError(f"bad port: {raw!r}") from e
try:
load_port("abc")
except ConfigError as e:
print("Caught:", e)
print("Cause:", repr(e.__cause__))
# Caught: bad port: 'abc'
# Cause: ValueError("invalid literal for int() with base 10: 'abc'")In a real traceback, chaining prints both errors joined by "The above exception was the direct cause of the following exception." You get a clean domain error and the technical root cause.
2 else, finally & Re-raising
A full try statement has four clauses. else runs only on success; finally runs always; and a bare raise inside except re-raises the current exception after you've done something with it:
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print(" cannot divide by zero")
return None
else:
print(" division succeeded") # only if no exception
return result
finally:
print(" cleanup runs no matter what") # always
print(divide(10, 2))
print("---")
print(divide(10, 0))
# (10, 2): division succeeded / cleanup ... / 5.0
# (10, 0): cannot divide ... / cleanup ... / Noneclass OrderError(Exception):
"""Base for order problems."""
class OutOfStockError(OrderError):
pass
class PaymentError(OrderError):
pass
def place_order(item, in_stock, paid):
if not in_stock:
raise OutOfStockError(f"{item} is out of stock")
if not paid:
raise PaymentError(f"payment failed for {item}")
return f"Order for {item} confirmed"
for item, stock, paid in [("Book", True, True),
("Pen", False, True),
("Mug", True, False)]:
try:
print(place_order(item, stock, paid))
except OrderError as e: # one catch for the family
print(f" {type(e).__name__}: {e}")
# ✅ Expected output:
# Order for Book confirmed
# OutOfStockError: Pen is out of stock
# PaymentError: payment failed for Mug3 Reporting Many Errors with ExceptionGroup
Sometimes several things fail at once and you want to report all of them, not just the first. Python 3.11 added ExceptionGroup and the except* syntax to raise and handle a bundle of exceptions:
def run_checks():
errors = []
for name, ok in [("disk", False), ("net", True), ("ram", False)]:
if not ok:
errors.append(ValueError(f"{name} check failed"))
if errors:
raise ExceptionGroup("startup checks", errors)
try:
run_checks()
except* ValueError as eg: # note the star
for exc in eg.exceptions:
print("Handled:", exc)
# Handled: disk check failed
# Handled: ram check failed🎯 When to Catch — and When Not To
Catching an exception is a commitment to handle it. If you can recover, substitute, retry, or translate it, catch it. If you can't, let it propagate — and never silence one you don't understand:
def parse_count(raw, default=0):
try:
return int(raw)
except ValueError:
# we CAN handle this: fall back to a sensible default
return default
print(parse_count("42")) # 42
print(parse_count("oops")) # 0 (recovered)
# Re-raise what you can't handle, after logging it:
def risky(n):
try:
return 100 // n
except ZeroDivisionError:
print(" logging: division by zero for n =", n)
raise # bare raise -> re-raise the SAME error
try:
risky(0)
except ZeroDivisionError as e:
print("Propagated:", type(e).__name__)🎯 Your Turn: Fill in the Blank
Complete the custom exception and the chaining. Replace each ___, then run it.
# 🎯 YOUR TURN: replace each ___
class ParseError(___): # base class to subclass for errors
pass
def to_int(raw):
try:
return int(raw)
except ValueError as err:
raise ParseError(f"not a number: {raw!r}") ___ err # chain it
try:
to_int("xyz")
except ParseError as e:
print("Error:", e)
print("Cause is ValueError:", isinstance(e.__cause__, ___))
# Expected output:
# Error: not a number: 'xyz'
# Cause is ValueError: Trueclass ParseError(Exception):
pass
def to_int(raw):
try:
return int(raw)
except ValueError as err:
raise ParseError(f"not a number: {raw!r}") from err
try:
to_int("xyz")
except ParseError as e:
print("Error:", e) # not a number: 'xyz'
print("Cause is ValueError:", isinstance(e.__cause__, ValueError)) # TrueCommon Errors
❌ Subclassing the wrong base
class MyError(BaseException): # too low-level!
pass # catches things like KeyboardInterrupt✅ Subclass Exception (not BaseException) for application errors:
class MyError(Exception):
passtry:
do_work()
except Exception:
pass # bug vanishes without a trace✅ Catch what you can handle, and at least log the rest before re-raising with a bare raise.
❌ Losing the original cause
except ValueError:
raise ConfigError("bad config") # cause hidden as "context"✅ Use from to keep the chain explicit: raise ConfigError("bad config") from err.
🎯 Mini Challenge: A Safe JSON Loader
Wrap json.loads so that any parse failure raises your own DataError chained from the original, while a clean value passes straight through.
import json
class DataError(Exception):
"""Our domain-level data problem."""
def load_config(text):
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise DataError(f"invalid config: {text!r}") from e
# A valid document parses fine
print(load_config('{"port": 8080}'))
# A broken document raises our DataError, chained
try:
load_config("{not json}")
except DataError as e:
print("Error:", e)
print("Chained from:", type(e.__cause__).__name__)
# ✅ Expected output:
# {'port': 8080}
# Error: invalid config: '{not json}'
# Chained from: JSONDecodeError📚 Keep Learning
- Errors and Exceptions (tutorial) — user-defined exceptions, clean-up actions, raising and chaining.
- Built-in Exceptions reference — the full hierarchy, including ExceptionGroup and BaseExceptionGroup.
- The try statement (and except*) — the precise semantics of except, else, finally, and except*.
Frequently Asked Questions
Lesson complete — your error handling is professional now!
You can design custom exception hierarchies, chain errors with raise ... from ..., bundle failures with ExceptionGroup, use else and finally correctly, re-raise deliberately, and decide when catching even makes sense. Your tracebacks will tell the whole story now.
Practice quiz
Which base class should application exceptions subclass?
- BaseException
- RuntimeError
- Exception
- object
Answer: Exception. Subclass Exception for application errors. BaseException is too low-level and would also catch things like KeyboardInterrupt.
If ValidationError and ConfigError both subclass AppError, what does 'except AppError' catch?
- Both ValidationError and ConfigError (the whole family)
- Only AppError instances
- Nothing
- Only ValidationError
Answer: Both ValidationError and ConfigError (the whole family). Catching a base class catches all its subclasses, so 'except AppError' grabs the whole family of app errors at once.
What does 'raise NewError(...) from original' do?
- Deletes the original exception
- Ignores NewError
- Re-runs the original code
- Raises NewError while recording original as its __cause__
Answer: Raises NewError while recording original as its __cause__. The 'from' syntax chains exceptions: it raises the new error while storing the original in __cause__, preserving the root cause.
After 'raise ConfigError(...) from e', where is the original exception stored?
- e.__context__ only
- e.__cause__ of the new exception
- It is lost
- In a global variable
Answer: e.__cause__ of the new exception. The original is recorded as the new exception's __cause__, so you can inspect it and the traceback shows both errors.
When does the 'else' block of a try statement run?
- Only when the try body completed with no exception
- Always
- Only when an exception was raised
- Never
Answer: Only when the try body completed with no exception. The else clause runs only on success — when the try body raised no exception — and its own errors are not caught by the except.
When does the 'finally' block run?
- Only on success
- Only on failure
- No matter what — exception or not, even after a return
- Only if there is no except clause
Answer: No matter what — exception or not, even after a return. finally always runs, making it the right place for cleanup like closing files or releasing locks.
What does a bare 'raise' inside an except block do?
- Raises a new generic error
- Re-raises the current exception unchanged
- Silences the exception
- Returns None
Answer: Re-raises the current exception unchanged. A bare 'raise' re-raises the exception currently being handled, so you can log it and then let it propagate.
What does 'except* ValueError' (with the star) handle?
- A single ValueError
- All exceptions
- Only TypeErrors
- ValueErrors peeled from an ExceptionGroup
Answer: ValueErrors peeled from an ExceptionGroup. except* matches and handles the exceptions of a given type within an ExceptionGroup, which can bundle many exceptions together (Python 3.11+).
What is wrong with 'except Exception: pass'?
- It is a syntax error
- It silently swallows errors, hiding real bugs
- It only works once
- It re-raises automatically
Answer: It silently swallows errors, hiding real bugs. Silently swallowing exceptions hides bugs (even typos). Catch only what you can handle, and at least log the rest.
In 'load_port' that does 'raise ConfigError(...) from e' on a bad int, what is e.__cause__?
- A ConfigError
- None
- A ValueError
- A TypeError
Answer: A ValueError. int('abc') raises ValueError, which is captured as e and becomes the __cause__ of the chained ConfigError.
Continue this course
- Previous: Scope: global, nonlocal & LEGB
- Next: Time Zones with zoneinfo — Aware datetimes, converting between zones, and storing UTC correctly
- Quick reference: Python cheat sheet › Exceptions
- From the blog: Error Handling Best Practices in Python
Frequently asked questions
Why create custom exception classes?
Custom exceptions give your errors meaningful names and let callers catch exactly the failure they care about. Catching ValidationError is far clearer and safer than catching a generic ValueError that could come from anywhere. A small hierarchy of app-specific exceptions also lets callers catch a whole category at once by catching the base class.
What does 'raise ... from ...' do?
It chains exceptions: it raises a new, higher-level exception while recording the original one as its __cause__. The traceback then shows both, with the line 'The above exception was the direct cause of the following exception.' This preserves the root cause while letting you present a cleaner, domain-specific error to callers.
What's the difference between else and finally in a try block?
The else block runs only if the try body completed with no exception — it's for code that should run on success but that you don't want wrapped in the try. The finally block runs no matter what — exception or not, return or not — so it's where you put cleanup like closing files or releasing locks.
When should I catch an exception versus let it propagate?
Catch an exception only when you can do something meaningful about it: recover, retry, substitute a default, or translate it into a clearer error. If you can't handle it sensibly, let it propagate so it surfaces where someone can. Never silently swallow exceptions with a bare 'except: pass' — that hides bugs.