Part 1 · Foundations · Module 07

Errors & Exceptions

Things go wrong: files are missing, users type "twelve" where you wanted 12, networks drop. Exceptions are Python's way of saying "I can't continue here" and letting the right piece of code decide what to do. Handling them well is the difference between a script and a program people trust.

⏱ ~2 hoursPrerequisites: M06 Collections

Two kinds of errors

A syntax error means Python could not even understand your code, so nothing runs. Python reports it before execution starts, with a caret pointing at the problem.

An exception happens while the code is running: the code is valid Python, but an operation cannot be carried out with these values. When that happens, Python creates an exception object describing the problem and raises it. Normal execution stops, and Python looks for code that will handle it. If nothing does, the program ends and prints a traceback.

Every exception is an instance of a class, and the classes form a hierarchy. Catching a class also catches all its subclasses, which is why the hierarchy matters.

flowchart TD
  BE[BaseException] --> SE[SystemExit]
  BE --> KI[KeyboardInterrupt]
  BE --> EX[Exception]
  EX --> AE[ArithmeticError] --> ZDE[ZeroDivisionError]
  EX --> LE[LookupError]
  LE --> IE[IndexError]
  LE --> KE[KeyError]
  EX --> VE[ValueError] --> UDE[UnicodeDecodeError]
  EX --> TE[TypeError]
  EX --> NE[NameError] --> ULE[UnboundLocalError]
  EX --> OE[OSError] --> FNF[FileNotFoundError]
  EX --> ATE[AttributeError]
  EX --> RE[RuntimeError] --> RCE[RecursionError]
    

Notice that SystemExit and KeyboardInterrupt (Ctrl+C) are not under Exception. That is deliberate: except Exception should never stop a user from quitting your program.

Anatomy of a traceback

A traceback is the report Python prints for an unhandled exception. It lists the chain of calls that were active when the error happened, oldest first. Run this and read the output:

How to read it, every time:

  1. Start at the last line. ZeroDivisionError: division by zero gives the exception type and message. This is what went wrong.
  2. Read the frame just above it. The last File "...", line N, in average entry shows the exact line that raised, often with ~~~^~~~ markers under the failing expression.
  3. Walk upwards to see how you got there: average was called by report, which was called from the module top level. The bug is often in a caller that passed bad data (here, an empty list), not in the line that raised.

try, except, else, finally

Put code that might fail in a try block, and say how to recover in an except block. Two optional clauses complete the statement:

flowchart TD
  T[try block] -->|no exception| EL[else block]
  T -->|exception matches an except| EX[except block]
  T -->|exception matches nothing| F2[finally block] --> UP[exception propagates to the caller]
  EL --> F[finally block]
  EX --> F
  F --> NEXT[code after the try statement]
    

Step through to watch the jumps. Notice how control leaves the try block the instant an exception is raised, skipping the rest of it.

When an exception is not handled in the current function, it propagates: the function ends immediately and the exception is re-raised at the call site, then its caller, and so on up the stack. You can therefore handle errors at whatever level knows how to recover.

Catch specific exceptions

An except clause names the exception types it handles. Python checks clauses from top to bottom and runs the first one that matches, so put specific classes before general ones. Use as name to get the exception object.

try:
    value = settings[key]
except KeyError as e:
    print(f"missing setting: {e}")

To handle several types the same way, list them. Python 3.14 lets you drop the parentheses when you do not use as:

try:
    number = int(raw)
except ValueError, TypeError:
    number = 0

try:
    number = int(raw)
except (ValueError, TypeError) as e:   # with 'as', parentheses are still required
    print(e)
try:
    number = int(raw)
except (ValueError, TypeError):
    number = 0

Why never use a bare except:

A bare except: (or except BaseException:) catches everything, including KeyboardInterrupt, SystemExit, and the typo NameError you would want to see. It hides bugs. Even except Exception: is too broad unless you log or re-raise. Catch the narrowest exception you know how to handle and let the rest propagate.

The function quietly returns 0, and the typo goes unnoticed. With except KeyError: the NameError would surface immediately. If you must catch broadly (for example at the very top of a long-running service), log the error with its traceback, never pass.

EAFP vs LBYL

Two styles for code that might fail:

LBYL: Look Before You Leap. Check first, then act.

if key in config:
    port = int(config[key])
else:
    port = 8080

EAFP: Easier to Ask Forgiveness than Permission. Try it, handle failure.

try:
    port = int(config[key])
except KeyError:
    port = 8080

Python culture leans towards EAFP for three reasons. First, checks can be incomplete: key in config does not tell you whether the value is a valid integer. Second, checks can be out of date: a file that existed when you checked can be deleted before you open it (a race condition). Third, the common path stays uncluttered. LBYL is still right when failure is common and cheap to detect, or when the operation has side effects you cannot undo halfway.

The LBYL version disagrees with int() on "1_000" (which int accepts) and needs extra work for signs. EAFP asks the real authority, int itself.

Raising exceptions and custom hierarchies

Your own functions should raise exceptions when they are called with values they cannot work with. Raising is better than returning a special value like -1 or None, because the caller cannot accidentally ignore it.

def withdraw(balance: float, amount: float) -> float:
    if amount <= 0:
        raise ValueError(f"amount must be positive, got {amount}")
    if amount > balance:
        raise ValueError(f"insufficient funds: balance {balance}, requested {amount}")
    return balance - amount

Pick the built-in class that matches the problem: ValueError for a right-typed but wrong value, TypeError for the wrong type, KeyError/IndexError for missing items, NotImplementedError for a method still to be written. Inside an except block, a bare raise re-raises the current exception unchanged, which is useful after logging.

Custom exceptions

For a library or application, define your own exception classes. A custom class is a one-liner that subclasses Exception (classes are taught in Part 2; for now copy the pattern). Build a small hierarchy with one base class for your package, so callers can catch everything from your code with one clause, or specific failures individually.

Chaining and notes

Often you catch a low-level error and raise a higher-level one that makes sense to your caller: a KeyError deep in config parsing becomes a ConfigError("missing 'port'"). Use raise New(...) from original to chain them. The original is stored as __cause__ and printed in the traceback under "The above exception was the direct cause of the following exception", so no information is lost.

If you raise inside an except block without from, Python still records the original as __context__ and prints "During handling of the above exception, another exception occurred", which suggests a bug in the handler. Write from e to say "this is deliberate", or from None to hide the original when it is pure noise.

Notes (Python 3.11+) attach extra context to an exception as it travels up, without changing its type: e.add_note("while reading row 17"). Notes are printed after the message.

Exception groups and except*

Sometimes several independent things fail at once: validating ten fields of a form, or running five tasks concurrently. Reporting only the first failure is frustrating. An ExceptionGroup bundles several exceptions into one, and the except* clause handles the members by type. Each except* clause receives a sub-group of the matching exceptions; anything unhandled is re-raised.

You will meet exception groups again with asyncio.TaskGroup in M38, where several concurrent tasks can fail together.

Cleanup, and the new finally rule

finally exists for cleanup that must happen whatever occurs: releasing a lock, closing a file, printing "done". In M09 you will see that the with statement (a context manager) packages this pattern so you rarely write finally by hand.

A return, break, or continue that jumps out of a finally block is a trap: it silently discards any exception in flight and overrides any earlier return value.

Assertions, warnings, and good error messages

assert: checks for programmers

assert condition, "message" raises AssertionError if the condition is false. Use it to state assumptions that should always be true if your code is correct, an internal sanity check. Never use it to validate user input or data from outside: running Python with -O removes every assert. Tests are the main place you will write asserts (pytest is built on them, M17).

def mean(values):
    result = sum(values) / len(values)
    assert min(values) <= result <= max(values), "mean must lie within the data"   # internal invariant
    return result

warnings: problems that should not stop the program

A warning tells the developer "this works, but you should know something", for example that a function is deprecated. Warnings are printed once per location by default and can be turned into errors (the -W error flag) for testing.

Writing error messages that help

A good message answers three questions: what was wrong, which value caused it, and ideally what is expected.

UnhelpfulHelpful
ValueError("bad input")ValueError("age must be between 0 and 150, got -4")
KeyError("x") from deep insideConfigError("missing required setting 'port' in settings.toml")
Exception("error")LedgerError("jan.csv:17: invalid amount '12,3.4'")

Practice

Write safe_divide(a, b) that returns a / b, or None when b is zero. Use try/except, catching only ZeroDivisionError, so that other mistakes such as passing a string still raise.

Hint

except ZeroDivisionError: return None

Show solution
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

Write get_path(data, *keys, default=None) that follows keys through nested dicts and lists, for example get_path(cfg, "db", "hosts", 0). Return default if any step is missing (KeyError, IndexError) or impossible (TypeError, such as indexing an int). Use EAFP, not in checks.

Hint

Loop over keys, doing data = data[key] inside one try; catch the three exception types together.

Show solution
def get_path(data, *keys, default=None):
    try:
        for key in keys:
            data = data[key]
    except KeyError, IndexError, TypeError:
        return default
    return data

Create ValidationError(Exception) and two subclasses: MissingFieldError and RangeError. MissingFieldError(field) must store .field and have the message "missing field: <field>". Then write validate_age(record) that returns record["age"] as an int, raising MissingFieldError("age") if the key is absent and RangeError if the age is outside 0–150.

Hint

In __init__, call super().__init__(f"missing field: {field}") and then set self.field = field.

Show solution
class ValidationError(Exception):
    """Base class for validation problems."""


class MissingFieldError(ValidationError):
    def __init__(self, field):
        super().__init__(f"missing field: {field}")
        self.field = field


class RangeError(ValidationError):
    pass


def validate_age(record):
    try:
        age = int(record["age"])
    except KeyError:
        raise MissingFieldError("age") from None
    if not 0 <= age <= 150:
        raise RangeError(f"age must be between 0 and 150, got {age}")
    return age

Define ConfigError(Exception) and write load_port(config) that returns int(config["port"]). A missing key or a non-numeric value must raise ConfigError chained with from to the original exception. Ports outside 1–65535 raise ConfigError too.

Hint

Use two except clauses, each with raise ConfigError(...) from e. Do the range check after the try.

Show solution
class ConfigError(Exception):
    pass


def load_port(config):
    try:
        port = int(config["port"])
    except KeyError as e:
        raise ConfigError("missing setting 'port'") from e
    except ValueError as e:
        raise ConfigError(f"port must be a number, got {config['port']!r}") from e
    if not 1 <= port <= 65535:
        raise ConfigError(f"port must be 1-65535, got {port}")
    return port

Write parse_all(values) that converts every string in values to an int. If any fail, raise one ExceptionGroup with the message "invalid values" containing the ValueError for each bad item. If all succeed, return the list of ints.

Hint

Collect results and errors in two lists inside the loop; raise ExceptionGroup("invalid values", errors) afterwards if errors is not empty.

Show solution
def parse_all(values):
    results, errors = [], []
    for value in values:
        try:
            results.append(int(value))
        except ValueError as e:
            errors.append(e)
    if errors:
        raise ExceptionGroup("invalid values", errors)
    return results

Mini-project: a robust input validator

Mini-projectvalidate.py: forms that never crash

Write a module that asks the user for a sign-up form (name, age, email, and a password typed twice) and never crashes, no matter what they type. Each field has a validator function that returns the cleaned value or raises a specific exception from your own hierarchy.

mkdir validator && cd validator
pixi init
pixi add python=3.14
pixi run python validate.py
  • FormError(Exception) with subclasses RequiredError, FormatError, RangeError, and MismatchError; each stores the field name.
  • Validators: validate_name, validate_age (13–120), validate_email (one @ and a dot after it), validate_password (length ≥ 10, at least one digit).
  • ask(prompt, validator, attempts=3) loops until valid, printing the error message after each failure, and raises FormError chained from the last failure after too many attempts.
  • validate_form(data: dict) validates everything at once and raises an ExceptionGroup listing every problem, for use by a future web form.
  • Every validator raises a specific subclass with a message containing the bad value
  • No bare except and no except Exception: pass anywhere
  • ask uses try/except/else with a small try block
  • Too many attempts raise FormError(...) from last_error
  • validate_form reports all invalid fields with ExceptionGroup, handled with except* in main
  • Ctrl+C exits cleanly with a friendly message (catch KeyboardInterrupt only in main)

Stretch goals: attach notes with add_note naming the attempt number; emit a DeprecationWarning from an old check_email alias; write assert-based checks at the bottom of the file for each validator.

Quiz

Recap

Going further