7  Errors and Exceptions

NoteCore idea

An exception is an object that represents an error. Code that raises an exception interrupts normal flow until something catches it. The try / except / else / finally block lets you separate the happy path from the recovery path — and with lets you guarantee cleanup even when things go wrong.

In this chapter you will learn to:

  1. Catch exceptions with try / except and re-raise the ones you can’t handle.
  2. Use else and finally correctly.
  3. Raise exceptions yourself and chain them with from.
  4. Build a custom exception hierarchy for an application.
  5. Manage resources with with and write a context manager.

7.1 Reading a traceback

When an exception goes uncaught, Python prints a traceback — the chain of calls that led to the failure. Read it from the bottom up: the last line is the error type and message; the lines above show the call path.

def divide(a, b):
    return a / b

divide(10, 0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[1], line 4
      1 def divide(a, b):
      2     return a / b
      3 
----> 4 divide(10, 0)

Cell In[1], line 2, in divide(a, b)
      1 def divide(a, b):
----> 2     return a / b

ZeroDivisionError: division by zero

ZeroDivisionError: division by zero — that’s the what. The frames above are the where.

7.2 try / except

The default behavior — let the exception propagate up and crash the program — is right surprisingly often. But sometimes you have a sensible recovery: substitute a default, log and continue, return None. try / except lets you intercept a specific exception and run a handler instead.

def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return float("inf")

[safe_divide(10, 2), safe_divide(10, 0)]
[5.0, inf]
  • The try: block is the protected code. If a / b raises ZeroDivisionError, control jumps to the matching except clause — the rest of the try is abandoned.
  • except ZeroDivisionError: matches only that specific class (and its subclasses). Other exceptions still propagate.
  • The handler returns float("inf") as a graceful fallback for division by zero.
  • The first call (10 / 2) never triggers the handler; the second call (10 / 0) does.

The general rule: catch the narrowest exception class that fits the recovery you actually have. Bind the exception to a name with as to inspect it:

try:
    int("not a number")
except ValueError as e:
    message = f"can't parse: {e}"
message
"can't parse: invalid literal for int() with base 10: 'not a number'"
  • int("not a number") raises ValueError("invalid literal for int() with base 10: 'not a number'").
  • as e binds the exception object to e so the handler can read its message, args, or chained cause.
  • f"can't parse: {e}" calls str(e) to format the exception’s message.

The general rule: except SomeError as name: gives you the exception object for inspection or re-raising. You can catch several types:

def parse_int(s):
    try:
        return int(s)
    except (ValueError, TypeError) as e:
        return f"error: {e}"

[parse_int("42"), parse_int("nope"), parse_int(None)]
[42,
 "error: invalid literal for int() with base 10: 'nope'",
 "error: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'"]
  • The tuple (ValueError, TypeError) matches either class.
  • int("nope") raises ValueError; int(None) raises TypeError — both go to the same handler.
  • Pass each case through and you get a successful parse, then two formatted error messages.

The general rule: except (A, B): is the way to match multiple unrelated classes in one handler.

Never use a bare except:. It catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit. If you really need a catch-all, write except Exception: — that omits the system-exit family.

7.3 else and finally

A try block has two related extensions. else is the “no exception was raised” branch — useful when you want to keep the try block as small as possible. finally is the “always run, exception or not” branch — useful for cleanup that must happen regardless of outcome.

def parse_log_line(line):
    try:
        n = int(line)
    except ValueError:
        return None
    else:
        return n * 2
    finally:
        # always runs — useful for cleanup, logging, releasing resources
        pass

[parse_log_line("21"), parse_log_line("oops")]
[42, None]
  • try: runs the parse. Only the line that might raise is in the try.
  • except ValueError: returns None for non-numeric input.
  • else: runs only when the try succeeded — it has access to n. Doubling lives here, not in the try, so we never accidentally catch an exception from the doubling.
  • finally: runs no matter what — after else, after except, even if return already fired in either branch.

The general rule: try for the protected operation, except for recovery, else for “happy path follow-up”, finally for unconditional cleanup.

7.4 Raising exceptions

You raise an exception when something is wrong that callers must know about.

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"age must be int, got {type(age).__name__}")
    if not 0 <= age <= 150:
        raise ValueError(f"age {age} is not realistic")
    return age

set_age("not a number")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 8
      4     if not 0 <= age <= 150:
      5         raise ValueError(f"age {age} is not realistic")
      6     return age
      7 
----> 8 set_age("not a number")

Cell In[6], line 3, in set_age(age)
      1 def set_age(age):
      2     if not isinstance(age, int):
----> 3         raise TypeError(f"age must be int, got {type(age).__name__}")
      4     if not 0 <= age <= 150:
      5         raise ValueError(f"age {age} is not realistic")
      6     return age

TypeError: age must be int, got str

Two distinct exception classes guard the two distinct failure modes. TypeError is for “wrong kind of value” — passing a str where an int was expected. ValueError is for “right kind, wrong value” — an int outside the realistic range. Choosing the right class matters because callers’ except clauses match by type: code that wants to recover from age-out-of-range should write except ValueError, and a TypeError should propagate unchecked. The f"age must be int, got {type(age).__name__}" formats a useful diagnostic — telling the user what we got, not just what we wanted.

Use raise X from Y to chain — the traceback shows both the original cause and the new exception:

import json

def load_config(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise ValueError("config file is not valid JSON") from e

load_config("{ bad json")
---------------------------------------------------------------------------
JSONDecodeError                           Traceback (most recent call last)
Cell In[7], line 7, in load_config(text)
      5         return json.loads(text)
      6     except json.JSONDecodeError as e:
----> 7         raise ValueError("config file is not valid JSON") from e

File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/__init__.py:352, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
    349 if (cls is None and object_hook is None and
    350         parse_int is None and parse_float is None and
    351         parse_constant is None and object_pairs_hook is None and not kw):
--> 352     return _default_decoder.decode(s)
    353 if cls is None:

File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/decoder.py:345, in JSONDecoder.decode(self, s, _w)
    341 """Return the Python representation of ``s`` (a ``str`` instance
    342 containing a JSON document).
    343 
    344 """
--> 345 obj, end = self.raw_decode(s, idx=_w(s, 0).end())
    346 end = _w(s, end).end()

File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/decoder.py:361, in JSONDecoder.raw_decode(self, s, idx)
    360 try:
--> 361     obj, end = self.scan_once(s, idx)
    362 except StopIteration as err:

JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 3 (char 2)

The above exception was the direct cause of the following exception:

ValueError                                Traceback (most recent call last)
Cell In[7], line 9
      5         return json.loads(text)
      6     except json.JSONDecodeError as e:
      7         raise ValueError("config file is not valid JSON") from e
      8 
----> 9 load_config("{ bad json")

Cell In[7], line 7, in load_config(text)
      3 def load_config(text):
      4     try:
      5         return json.loads(text)
      6     except json.JSONDecodeError as e:
----> 7         raise ValueError("config file is not valid JSON") from e

ValueError: config file is not valid JSON

The try / except catches the low-level json.JSONDecodeError and re-raises as a ValueError whose message describes the problem in the caller’s vocabulary (the caller knows about config files; they shouldn’t have to know about JSON internals). The from e clause attaches the original exception as e.__cause__ on the new one, so the printed traceback shows both:

json.decoder.JSONDecodeError: Expecting property name ...
The above exception was the direct cause of the following exception:
ValueError: config file is not valid JSON

That two-frame traceback is the value of from e: callers see the abstraction-appropriate error; debuggers still see the raw cause. Without from e, Python prints the chain anyway but with “During handling of the above exception, another exception occurred” — that’s the implicit chain (called __context__), reserved for accidental cascades.

7.5 The exception hierarchy

All exceptions inherit from BaseException. Most “normal” errors inherit from Exception — that’s the level you usually catch.

BaseException
 ├── SystemExit          # raised by sys.exit() — don't catch
 ├── KeyboardInterrupt   # Ctrl+C — don't catch
 └── Exception           # catch this level for general errors
      ├── ValueError
      ├── TypeError
      ├── KeyError
      ├── IndexError
      ├── AttributeError
      ├── OSError
      │    ├── FileNotFoundError
      │    └── PermissionError
      └── ZeroDivisionError, ImportError, RuntimeError, …

The rule: catch the most specific exception that fits. Catching Exception everywhere hides bugs.

7.6 Custom exceptions

Catching Exception is too broad; catching ValueError everywhere a domain error happens is sloppy. The right move is to define your own hierarchy: a base class for “any error from this app” plus a subclass per logical category. Callers then handle specific errors by class, never by string-matching. Build it in three small steps.

Step 1: a custom exception is just a class. Inherit from Exception and you’re done — no body required:

class AppError(Exception):
    """Base exception for this application."""

try:
    raise AppError("something went wrong")
except AppError as e:
    str(e)
  • class AppError(Exception): declares an empty subclass of Exception. The docstring is the whole body — that’s enough for a marker class.
  • The string passed to raise AppError(...) becomes the message that str(e) returns; Exception.__init__ does that for you.

Step 2: build a hierarchy. Add a subclass per category — callers can catch the base for any-of-ours, or a specific one:

class AppError(Exception):
    """Base exception for this application."""

class DataError(AppError):
    """Raised when data is invalid or missing."""

class AuthError(AppError):
    """Raised when authentication fails."""

try:
    raise DataError("bad row 42")
except AppError as e:
    type(e).__name__, str(e)
  • DataError and AuthError extend AppError, so any except AppError: clause catches both.
  • The exception caught is exactly DataErrortype(e).__name__ shows the concrete class. except AppError matched because DataError is-a AppError.
  • A caller who wants to handle DataError differently from AuthError writes two except clauses, most-specific first.

Step 3: carry structured data. Sometimes a message string is not enough — you want fields the caller can inspect (a user id, a reason code). Override __init__ and call super().__init__(...) to set the message:

class AuthError(AppError):
    """Raised when authentication fails."""
    def __init__(self, user, reason):
        self.user = user
        self.reason = reason
        super().__init__(f"auth failed for {user!r}: {reason}")

try:
    raise AuthError("alice", "wrong password")
except AuthError as e:
    e.user, e.reason, str(e)
  • __init__ stores user and reason on the instance — callers can read them programmatically without parsing the message.
  • super().__init__(message) calls the parent class’s (Exception’s) __init__ to set the formatted string that str(e) returns. super() is the standard way to invoke a parent class’s behaviour from a subclass — the deep treatment is in Chapter 9. Forgetting this call is the classic bug: str(e) would come back empty.
  • Catching code can now branch on e.reason instead of string-matching the message.

The general rule: subclass Exception for an app-specific base, then add a subclass per error category. Override __init__ only when you need structured fields beyond a plain message.

7.7 with and context managers

When you open a file, you eventually need to close it — even if an exception interrupts the work in between. Hand-rolled try / finally works but clutters every call site. The with statement bundles “set up the resource, do the work, tear it down no matter what” into one block, by calling the object’s __enter__ and __exit__ methods. The most common use is opening a file:

import tempfile
from pathlib import Path

target = Path(tempfile.mkdtemp()) / "hello.txt"
target.write_text("hello\nworld\n", encoding="utf-8")

with open(target, encoding="utf-8") as f:
    content = f.read()
content
'hello\nworld\n'
  • tempfile.mkdtemp() returns the path of a fresh, empty temporary directory; Path (from pathlib, the modern path-handling module covered in Chapter 8) wraps that path so we can use / to compose subpaths — target ends up as something like /tmp/tmpAbCdEf/hello.txt.
  • target.write_text(...) is pathlib’s one-line write — it opens the file, writes the text, and closes it. We’re using it just to set up the input for the demo; the topic is what comes next.
  • with open(target, encoding="utf-8") as f: opens the file, binds it to f, and registers a guarantee: when the block exits — normally or via exception — f.close() is called.
  • content = f.read() reads while the file is open.
  • The moment the indented block ends, the file is closed. There’s no way to forget.

The general rule: any time a resource needs cleanup, look for or write a context manager and use it with with. You can write your own context manager with contextlib.contextmanager:

import time
from contextlib import contextmanager

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {time.perf_counter() - start:.4f}s")

with timer("compute"):
    total = sum(range(1_000_000))

total
compute: 0.0206s
499999500000
  • @contextmanager turns a generator function into a context manager. The single yield splits the function into “before the block” (setup) and “after the block” (teardown).
  • Code before yield runs on __enter__ — here, capturing the start time.
  • Code after yield, inside finally, runs on __exit__ — guaranteed to print the elapsed time even if the body raises.
  • with timer("compute"): enters, runs the body, exits — and the timing line prints.

The general rule: write the setup, yield, then the teardown wrapped in try/finally@contextmanager handles the protocol wiring for you.

For the common case “swallow this specific exception silently,” contextlib.suppress is more concise than a try / except that does nothing:

from contextlib import suppress
from pathlib import Path

# Delete a file if it exists; do nothing if it doesn't:
target = Path("/tmp/does-not-exist-xyz")
with suppress(FileNotFoundError):
    target.unlink()
"continued without error"
'continued without error'

Use it sparingly — silently ignored exceptions are bugs in disguise. It earns its place when the absence of the resource is genuinely a no-op.

7.8 Multiple exceptions at once: ExceptionGroup

Sometimes a single operation produces several independent failures — a parallel task fan-out, a batch validation, a multi-file write. Python 3.11 added ExceptionGroup to bundle them, and except* to catch by type without unbundling the rest:

def validate_all():
    raise ExceptionGroup("invalid input", [
        ValueError("age out of range"),
        TypeError("name must be a string"),
    ])

try:
    validate_all()
except* ValueError as eg:
    print("value problems:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("type problems:", [str(e) for e in eg.exceptions])
value problems: ['age out of range']
type problems: ['name must be a string']

The * says “match any leaf exception of this type inside the group.” Both except* clauses run; you don’t have to choose one. For everyday single-error code, plain except is still the right tool — ExceptionGroup is for genuinely concurrent or batch failures, where reporting all problems is the point.

The deep treatment of with, including the __enter__ / __exit__ protocol and contextlib.suppress, is in Chapter 30.

TipWhy this matters

Exceptions separate the happy path from the recovery path. Code with explicit error returns (if result is None: …) clutters every call site; code with exceptions reads as the algorithm. The with statement makes resource leaks impossible — a guarantee that’s hard to provide with manual try / finally.

7.9 Going deeper

with, match, and the orphaned else clauses on for and while loops are the subject of Chapter 30. The descriptor protocol behind __enter__ / __exit__ and the broader data model is Chapter 13.

7.10 Build: a robust JSON config loader

Real applications load configuration from a file, parse it, and complain clearly when something’s wrong. We’ll build load_config(path) that exercises custom exceptions, exception chaining, multiple except clauses, and with for file lifecycle — all the chapter’s pieces in one running example.

Step 1: a small exception hierarchy. Two classes — a base for “any config problem” and a specific subclass for “a required key is missing”:

class ConfigError(Exception):
    """Base exception for config-loading failures."""

class MissingKeyError(ConfigError):
    def __init__(self, key):
        self.key = key
        super().__init__(f"missing required key: {key!r}")

try:
    raise MissingKeyError("database_url")
except ConfigError as e:
    type(e).__name__, e.key, str(e)

MissingKeyError carries a structured key field so callers can branch on the missing key without parsing the message. Catching ConfigError matches both classes — exactly the hierarchy from earlier in the chapter.

Step 2: chain a parse failure with from. When json.loads fails, we want the caller to see a ConfigError (our domain error) but still keep the original JSONDecodeError for debugging. raise X from Y does that:

import json

def parse_config(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise ConfigError(f"config is not valid JSON: {e.msg}") from e

try:
    parse_config("{ bad json")
except ConfigError as e:
    type(e).__name__, str(e), type(e.__cause__).__name__

from e sets e.__cause__ on the new exception — Python’s traceback uses it to print “The above exception was the direct cause of the following exception”. e.__cause__ is the original JSONDecodeError. Callers see a clean ConfigError; debuggers still see the raw cause.

Step 3: tie it together with with, validation, and a missing-file branch. The full loader opens the file (closing it on every exit path), parses, validates required keys, and raises a clean ConfigError for every failure mode:

import json, tempfile
from pathlib import Path

REQUIRED = ("host", "port")

def load_config(path):
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
    except FileNotFoundError:
        raise ConfigError(f"config file not found: {path}") from None
    except json.JSONDecodeError as e:
        raise ConfigError(f"config file is not valid JSON: {e.msg}") from e

    for key in REQUIRED:
        if key not in data:
            raise MissingKeyError(key)
    return data

# Set up a valid file and a missing-key file in a temp dir
tmp = Path(tempfile.mkdtemp())
(tmp / "good.json").write_text('{"host": "localhost", "port": 5432}')
(tmp / "partial.json").write_text('{"host": "localhost"}')

results = []
for name in ("good.json", "partial.json", "missing.json"):
    try:
        results.append(("ok", load_config(tmp / name)))
    except ConfigError as e:
        results.append(("err", type(e).__name__, str(e)))
results
[('ok', {'host': 'localhost', 'port': 5432}),
 ('err', 'MissingKeyError', "missing required key: 'port'"),
 ('err',
  'ConfigError',
  'config file not found: /tmp/tmp75vrc0yy/missing.json')]

Three new tricks here. The with open(...) block guarantees the file closes even if json.load raises — exactly the cleanup invariant with was designed for. from None suppresses the original FileNotFoundError from the chained traceback — useful when the original exception adds noise rather than information. And the for key in REQUIRED: validation loop raises the structured MissingKeyError at the first missing key — callers can read e.key to know which.

The build exercises every piece from the chapter: custom exception hierarchy, super().__init__, multiple except clauses, chaining (from e) vs. suppression (from None), with for file lifecycle, and validation as raise-on-failure. It’s also the shape of nearly every real-world config loader.

7.11 Exercises

  1. Specific over generic. Catch only FileNotFoundError for a missing file, returning a default. Run again with except Exception — what bugs would the broad catch hide?

  2. Chain a parse error. Write parse_age(s) that calls int(s) and re-raises ValueError(f"age must be a number, got {s!r}") — chained with from.

  3. Custom hierarchy. Define ConfigError(Exception) with subclasses MissingKeyError and BadValueError. Write a function that raises one or the other.

  4. with for cleanup. Write a context manager chdir(path) that changes the working directory on entry and restores it on exit, even if the body raises.

  5. finally order. Predict the print order of: try: print("a"); raise ValueError("x") except ValueError: print("b") finally: print("c").

7.12 Summary

try / except / else / finally cover error handling; raise and from cover signaling; with covers resource lifecycles. The next chapter, Chapter 8, applies all of this to the most common source of errors in real programs — talking to the file system.