Exceptions in depth

Python · Practical · 12 min

Robust code and good library APIs depend on raising and handling the right errors the right way.

A full try statement has four parts: try, one or more except, an optional else that runs only if no exception occurred, and finally, which always runs (for cleanup). Putting follow-up code in else rather than in try keeps the protected region minimal, so you don't accidentally catch errors from code you didn't mean to guard.

Exceptions are classes arranged in a hierarchy rooted at BaseException. Application errors derive from Exception; KeyboardInterrupt and SystemExit deliberately don't, so except Exception won't swallow Ctrl-C. An except clause matches subclasses too, so order clauses from most to least specific. Catch several types with a tuple: except (KeyError, IndexError):.

For a library or larger application, define your own hierarchy: one base class (class PaymentError(Exception)) and specific subclasses. Callers can then catch everything from you with one clause, or a specific case.

Inside an except block, a bare raise re-raises the current exception with its traceback intact. When translating a low-level error into your own, use chaining: raise ConfigError("bad config") from err. The traceback shows both, linked by "The above exception was the direct cause...". Use from None to hide the original when it is pure noise.

Python 3.11 added e.add_note("context") to attach extra information, and exception groups: ExceptionGroup bundles several unrelated errors (for example from concurrent tasks), and except* handles each matching type within the group.

Example

class AppError(Exception):
    "Base class for this application's errors."

class ConfigError(AppError):
    pass

def load_port(settings):
    try:
        port = int(settings["port"])
    except KeyError:
        raise ConfigError("missing 'port'") from None
    except ValueError as err:
        raise ConfigError(f"bad port {settings['port']!r}") from err
    else:
        return port
    finally:
        print("checked", settings)

for s in [{"port": "8080"}, {}, {"port": "eighty"}]:
    try:
        print(load_port(s))
    except AppError as e:
        print(type(e).__name__, e, "| cause:", repr(e.__cause__))

Remember

  • else runs when no exception occurred; finally always runs
  • Derive custom errors from Exception via a single base class
  • Use raise New(...) from err to chain, bare raise to re-raise

Watch out for

  • Putting except Exception: before more specific clauses, making them unreachable.

Check yourself

  1. When does the `else` block of a try statement run?
  2. Which exception is NOT caught by `except Exception:`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/exceptions.html