Exceptions in depth
Error hierarchies, chaining, notes, exception groups and warnings.
Create a free account Try part of it here
Why it matters
The errors lesson taught you to read tracebacks and catch the failures you expect. Larger programs need more: errors that callers can catch by kind, messages that keep the original cause, importers that report every bad row instead of stopping at the first, and warnings for things that still work but soon won't. Good error handling is much of the difference between a script and a tool other people trust.
What you'll be able to do
- Design a small hierarchy of your own exceptions, with useful attributes
- Chain exceptions with raise ... from, and add context with add_note
- Choose between checking first and trying first, and use else and finally well
- Report many failures at once with ExceptionGroup and except*, and warn without failing
What it covers
- Your own exceptions
- Keep the cause: raise ... from
- Check first, or just try?
- A clearer error, with its cause
- Many failures at once: ExceptionGroup
- Warnings: still works, but...
- Worked example: an importer that reports everything
Your own exceptions
Built-in exceptions like ValueError say what kind of thing went wrong, but not that it went wrong in your code. A module or library usually defines its own small family of exceptions, with one base class that callers can catch to handle anything from it:
class ShopError(Exception): """Base class for every error this module raises.""" class OutOfStock(ShopError): def __init__(self, sku, wanted, available): super().__init__(f"{sku}: wanted {wanted}, only {available} left") self.sku, self.wanted, self.available = sku, wanted, available class UnknownProduct(ShopError, KeyError): pass STOCK = {"TE-101": 2, "PA-108": 0} def reserve(sku, qty): if sku not in STOCK: raise UnknownProduct(sku) if STOCK[sku] < qty: raise OutOfStock(sku, qty, STOCK[sku]) STOCK[sku] -= qty for sku, qty in [("TE-101", 1), ("PA-108", 1), ("XX-999", 1)]: try: reserve(sku, qty) print("reserved", sku) except OutOfStock as e: print("short by", e.wanted - e.available, "->", e) except ShopError as e: print("other shop problem:", repr(e))
Three design rules make exceptions pleasant to use:
- One base class per module or package, so
except ShopErrorcatches all of them and nothing else. - Attributes, not just a message.
e.availablelets the caller decide what to do; parsing a message string to get the number back would be fragile. - Inherit from a built-in too when it fits.
UnknownProductis also aKeyError, so code that already catchesKeyErrorkeeps working.
Order matters in a chain of except clauses: Python takes the first one that matches, so put the most specific exceptions first.
A useful rule of thumb for where to handle errors: raise them deep down, where the problem is detected, and catch them high up, where someone can decide what to do. A function that reserves stock shouldn't decide whether to show a message, retry or give up; the web page, command or job that called it knows which is right. Catching in the middle usually just loses information.
else runs only when the try block raised nothing; finally runs every time, even after a return. Step through both calls to compare their paths.
Keep the cause: raise ... from
Often you catch a low-level error and raise a clearer one for your callers. Use raise NewError(...) from err so the original isn't lost: the traceback then shows both, joined by "The above exception was the direct cause of the following exception".
import json class ConfigError(Exception): pass def load_config(text): try: data = json.loads(text) except json.JSONDecodeError as err: raise ConfigError("settings file is not valid JSON") from err if "currency" not in data: err = ConfigError("settings file has no currency") err.add_note("Add a line like \"currency\": \"USD\" to the file.") raise err return data for text in ['{"currency": "USD"}', '{"currency": "USD",}', '{"locale": "en"}']: try: print(load_config(text)) except ConfigError as e: print(type(e).__name__, "|", e, "| caused by:", type(e.__cause__).__name__ if e.__cause__ else None, "| notes:", getattr(e, "__notes__", []))
err.__cause__ holds the original exception, so a logger or an error tracker can show the whole story. add_note (Python 3.11+) attaches extra lines that appear under the traceback. It's the right place for a hint about how to fix the problem, without cramming everything into the message.
Use raise ... from None when the original error really is noise. And never write except Exception: pass: it hides every bug in the block, including the ones you haven't written yet.
try: try: {}["missing"] except KeyError as err: raise ValueError("bad config") from err except ValueError as e: print(type(e.__cause__).__name__, e)
Show the answer
KeyError bad config
raise ... from err stores the KeyError as the new exception's __cause__; the message is the ValueError's own.
Check first, or just try?
Two styles answer "what if this might fail?". Look before you leap checks first: if key in prices:. Easier to ask forgiveness than permission tries, and handles the failure: try: prices[key] with except KeyError. Python code leans toward the second, for good reasons:
- The check can go stale.
if os.path.exists(path): open(path)can still fail if the file is deleted between the two lines. Trying, and handling the error, has no such gap. - It says the common case first. The code reads as what normally happens, with the unusual case handled separately.
- It avoids doing work twice, like looking a key up once to check and again to use it.
Checking first is better when failure is common rather than exceptional (an if is cheaper than an exception), or when trying has side effects you can't undo, like charging a card. Keep try blocks small: wrap only the line that can fail, so you don't accidentally catch a different error from somewhere else.
A clearer error, with its cause
Write price_of(sku, prices): return prices[sku], but if the SKU is missing, raise UnknownSku (a LookupError subclass you define) with the message no price for <sku>, chained from the original KeyError, and with a note Check the SKU has two letters, a hyphen and three digits.
class UnknownSku(LookupError): pass def price_of(sku, prices): return prices[sku] print(price_of("TE-101", {"TE-101": 200}))
Many failures at once: ExceptionGroup
An importer that stops at the first bad row is frustrating: fix it, rerun, hit the next one. Better to collect every problem and report them together. Python 3.11 added ExceptionGroup for exactly this: one exception that carries a list of others. The new except* catches the matching ones out of the group, and lets the rest carry on up:
class RowError(Exception): def __init__(self, line, message): super().__init__(f"line {line}: {message}") self.line = line def check_rows(rows): problems = [] for n, (sku, qty) in enumerate(rows, start=1): if len(sku) != 6: problems.append(RowError(n, f"bad SKU {sku!r}")) if not qty.isdigit(): problems.append(ValueError(f"line {n}: qty {qty!r} isn't a whole number")) if problems: raise ExceptionGroup(f"{len(problems)} problems in the file", problems) try: check_rows([("TE-101", "2"), ("TENT", "1"), ("PA-108", "two")]) except* RowError as group: print("row errors:", [str(e) for e in group.exceptions]) except* ValueError as group: print("value errors:", [str(e) for e in group.exceptions])
Each except* clause receives a smaller group holding only the exceptions it matched, and several clauses can run for one group. That's different from a normal except, which stops at the first match. asyncio.TaskGroup uses the same mechanism to report every failed task, not just the first.
Warnings: still works, but...
Not every problem should stop the program. A warning says "this works for now, but you should change it": an old function name about to be removed, a setting that will change meaning, a suspicious value.
import warnings def total(prices, vat=None, tax=None): if vat is not None: warnings.warn("vat= is deprecated; use tax= instead", DeprecationWarning, stacklevel=2) tax = vat return round(sum(prices) * (1 + (tax or 0)), 2) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") print(total([10, 20], vat=0.2)) print([f"{w.category.__name__}: {w.message}" for w in caught])
stacklevel=2 points the warning at the line that called total, which is the line the caller needs to change. Libraries use DeprecationWarning to give everyone a release or two to update before the old way is removed. Tests can turn warnings into errors (python -W error, or pytest's settings), so they're noticed long before the removal arrives.
Worked example: an importer that reports everything
Parse order lines, collect a clear error for every bad line, and raise them all at once, so the person fixing the file sees the whole list in one go.
class ImportProblem(Exception): def __init__(self, line_no, message): super().__init__(f"line {line_no}: {message}") self.line_no = line_no def parse_orders(lines): orders, problems = [], [] for n, line in enumerate(lines, start=1): parts = line.split(",") if len(parts) != 3: problems.append(ImportProblem(n, f"expected 3 fields, got {len(parts)}")) continue sku, qty, price = (p.strip() for p in parts) try: orders.append((sku, int(qty), float(price))) except ValueError as err: problem = ImportProblem(n, f"can't read numbers in {line!r}") problem.__cause__ = err problems.append(problem) if problems: raise ExceptionGroup(f"{len(problems)} of {len(lines)} lines failed", problems) return orders lines = ["TE-101, 1, 200", "PA-108, two, 55", "FO-120, 3", "CA-132, 4, 5.00"] try: parse_orders(lines) except* ImportProblem as group: print(group.message) for problem in group.exceptions: print(" ", problem, "| cause:", type(problem.__cause__).__name__ if problem.__cause__ else "-")
The loop never stops early. Each bad line becomes an ImportProblem with its line number, and the number-conversion failure keeps its original ValueError as the cause. At the end, one ExceptionGroup reports all of them, and its message says how many lines failed out of how many. The person fixing the file gets the full list at once. A good habit for any validation: report everything wrong, not just the first thing.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why give a module one base exception class that all its errors inherit from?