Exception Hierarchies: When to Catch, When to Raise
18 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)An error is a message to the caller. Good error design gives callers a type they can catch, a message they can read, and a clear place where the problem is handled. This lesson shows how to do that.
Exceptions form a tree
Exception hierarchy is the tree of exception classes in which each class inherits from a parent. An except clause for a parent class also catches every child class.
Every exception inherits from another. KeyError and IndexError are both a LookupError. Catching a parent catches all its children, so catch the most specific type that still covers what you need.
print(issubclass(KeyError, LookupError))print(issubclass(ZeroDivisionError, ArithmeticError))print(issubclass(ValueError, KeyError))Example 1 · The built-in tree
print(issubclass(KeyError, LookupError), issubclass(IndexError, LookupError))print([c.__name__ for c in ZeroDivisionError.__mro__])
for data, key in [({"a": 1}, "b"), ([1, 2], 5)]: try: data[key] except LookupError as err: print(type(err).__name__, "caught as LookupError")True True ['ZeroDivisionError', 'ArithmeticError', 'Exception', 'BaseException', 'object'] KeyError caught as LookupError IndexError caught as LookupError
What this code does
__mro__lists the class and its parents, from child toobject.- One
except LookupErrorhandles both a missing key and a bad index.
The first two are True, the last is False. All ordinary errors inherit from Exception.
Your own hierarchy
Base exception is the one parent class that all of a package's own exceptions inherit from. A caller can catch it to handle every error that package raises.
Make one base class for your package, then a child for each kind of failure. Callers who do not care about the details catch the base. Callers who need a specific reaction catch a child.
class ShopError(Exception): pass
class OutOfStock(ShopError): pass
class PaymentDeclined(ShopError): passExample 2 · A hierarchy with specific handlers
class ShopError(Exception): pass
class OutOfStock(ShopError): pass
class PaymentDeclined(ShopError): pass
def place(order): if order == "pen": raise OutOfStock("pen") if order == "laptop": raise PaymentDeclined("card limit") return "ok"
for order in ["book", "pen", "laptop"]: try: print(place(order)) except OutOfStock as err: print("restock", err) except ShopError as err: print("shop error:", type(err).__name__, err)ok restock pen shop error: PaymentDeclined card limit
What this code does
- The specific handler comes first. If
ShopErrorcame first, it would catch both children. - Any other
ShopErrorfalls to the base handler.
Inherit from Exception, never from BaseException, which also covers KeyboardInterrupt and SystemExit.
Catch only what you can handle
Error boundary is the one place near the top of a program, such as a request handler, that catches all remaining errors, logs them once and returns a safe answer.
Catch an error where you can do something useful: retry, use a default, or show a clear message. If you cannot, let it rise. Never write a bare except: or an empty except Exception: pass, which hide real bugs.
def get_price(prices, item): try: return prices[item] except KeyError: return 0Example 3 · Catch where you can recover
def fetch(attempt): if attempt < 3: raise ConnectionError(f"network down on try {attempt}") return "data"
def fetch_with_retry(tries): for attempt in range(1, tries + 1): try: return fetch(attempt) except ConnectionError as err: print("retrying:", err) raise ConnectionError("gave up")
print(fetch_with_retry(3))try: fetch_with_retry(2)except ConnectionError as err: print("failed:", err)retrying: network down on try 1 retrying: network down on try 2 data retrying: network down on try 1 retrying: network down on try 2 failed: gave up
What this code does
- Retrying is a real way to handle the error, so we catch it here.
- When retries run out, the function lets a new error rise to its caller.
This is fine when 0 is a true default. If a missing price is a bug, do not hide it.
Re-raise with context, log once
Inside except, a bare raise passes the same error on. raise NewError(...) from err swaps in a clearer type and keeps the original as its cause. Log at the boundary, not at every layer, or one failure fills the log with copies.
class ProductNotFound(Exception): pass
def price_of(prices, item): try: return prices[item] except KeyError as err: raise ProductNotFound(item) from errExample 4 · raise from keeps the cause
class ProductNotFound(Exception): pass
def price_of(prices, item): try: return prices[item] except KeyError as err: raise ProductNotFound(item) from err
try: price_of({"pen": 20}, "bag")except ProductNotFound as err: print(type(err).__name__, err) print("cause:", type(err.__cause__).__name__)ProductNotFound bag cause: KeyError
What this code does
- The caller sees a
ProductNotFound, which names the problem in the shop's own words. __cause__holds the originalKeyError.
Example 5 · Log once at the boundary
import loggingimport sys
logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(levelname)s:%(name)s:%(message)s")log = logging.getLogger("shop")
def load(order_id): raise ValueError(f"order {order_id} has no items")
def handle(order_id): try: load(order_id) return "done" except Exception as err: log.error("request failed: %s", err) return "Something went wrong"
print(handle(7))ERROR:shop:request failed: order 7 has no items Something went wrong
What this code does
loaddoes not catch or log. The error rises tohandle, the boundary.handlelogs one line and returns a safe message to the user.
The caller sees a domain error. error.__cause__ still holds the original KeyError for debugging.
Next step
Check your understanding
11 quick questions