Index Ai Academy
Phase 7 · Software engineering · Python in depth

Exceptions in depth

Error hierarchies, chaining, notes, exception groups and warnings.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Your own exceptions
  2. Keep the cause: raise ... from
  3. Check first, or just try?
  4. A clearer error, with its cause
  5. Many failures at once: ExceptionGroup
  6. Warnings: still works, but...
  7. 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))
It runs right here in your browser, with nothing to install.

Three design rules make exceptions pleasant to use:

  • One base class per module or package, so except ShopError catches all of them and nothing else.
  • Attributes, not just a message. e.available lets 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. UnknownProduct is also a KeyError, so code that already catches KeyError keeps 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.

Try it now

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.

Quick check
What does this print?
try:
    try:
        {}["missing"]
    except KeyError as err:
        raise ValueError("bad config") from err
except ValueError as e:
    print(type(e.__cause__).__name__, e)
  1. KeyError bad config
  2. None bad config
  3. ValueError bad config
  4. KeyError 'missing'
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.

Drill

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}))
In the lesson, your code is checked as you write it.

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

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 "-")
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why give a module one base exception class that all its errors inherit from?

  1. So callers can catch every error from the module with one except clause, and nothing else
  2. Because Python requires it
  3. To make the errors faster
  4. So the errors can't be caught

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson