Python Programming — Alpha School

Lesson 14: Error Handling in Depth and Logging

🎧 Listen to this lesson

Lesson 7 introduced exceptions as a debugging lens. This lesson wields them as a design tool: choosing *which* exception to catch, structuring code with else and finally, creating your own exception types so callers can react precisely, and recording what happened with the logging module instead of scattering prints. Well-handled errors are invisible to happy users and priceless to the developer reading the log afterwards.

The full try statement

A complete try has four parts, each with a job: the try block runs the risky code; except handles a failure; else runs only if nothing failed (keeping the success path out of the risky zone); and finally runs always — success, failure, even mid-return — which makes it the home for cleanup.

def describe_number(text):
    try:
        n = int(text)
    except ValueError:
        print(f"Sorry, {text!r} is not a whole number.")
    else:
        print(f"Double {n} is {n * 2}.")
    finally:
        print("Attempt finished.")

describe_number("5")
describe_number("abc")

Output:

Double 5 is 10.
Attempt finished.
Sorry, 'abc' is not a whole number.
Attempt finished.

Read the flow for both calls. For "5": conversion succeeds, except is skipped, else runs the success message, finally signs off. For "abc": int() raises, the else block is skipped entirely (it exists precisely so success code does not run on failure), the handler apologises, and finally *still* signs off. Putting the "Double" line in the try block instead of else would also work here — but in larger functions else prevents later success-path code from being accidentally guarded by handlers meant for the risky line only. Precision is the theme of this whole lesson.

Catch specifically, in the right order

Python's exceptions form a family tree: ValueError and IndexError are both children of Exception, and a handler catches its type *and its children*. Two rules follow. Catch the most specific type that tells you what went wrong, and order handlers specific-first — a generic handler placed first swallows everything and makes the specific ones unreachable:

def first_item_score(text):
    try:
        values = text.split(",")
        return int(values[3])
    except IndexError:
        return "not enough values"
    except ValueError:
        return "values must be numbers"

print(first_item_score("1,2,3"))
print(first_item_score("1,2,3,x"))
print(first_item_score("1,2,3,4"))

Output:

not enough values
values must be numbers
4

Each input fails (or succeeds) for a *different* reason, and the handlers report them distinctly — a user told "values must be numbers" can fix their input; a user told "something went wrong" cannot. Bare except: (catching everything, including your own typos raising NameError) and except Exception: pass (hiding failures entirely) are the two shapes professionals avoid: they convert loud, fixable errors into silent corruption.

Raising your own exceptions

Libraries raise; applications should too. When a function cannot honour its contract, raise an exception with a clear message instead of returning a sneaky sentinel like -1 that callers forget to check. Custom exception classes — inheriting from Exception, often through a project-specific base — let callers distinguish *your* domain errors from everything else. Here is the finance-tracker example completed, with validation, a small class hierarchy, and logging of successful operations:

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

class TrackerError(Exception):
    """Base class for finance tracker problems."""

class InvalidAmountError(TrackerError):
    """Raised when an amount is not positive."""

class CategoryError(TrackerError):
    """Raised when a category name is empty."""

def add_expense(expenses, amount, category):
    if amount <= 0:
        raise InvalidAmountError("Amount must be positive")
    if not category.strip():
        raise CategoryError("Category cannot be empty")
    expenses.append({"amount": amount, "category": category})
    logging.info("Added expense of $%.2f to %s", amount, category)

expenses = []
add_expense(expenses, 12.50, "Food")
print("Recorded:", len(expenses))
try:
    add_expense(expenses, -5, "Food")
except InvalidAmountError as e:
    print("Rejected:", e)
try:
    add_expense(expenses, 5, "  ")
except CategoryError as e:
    print("Rejected:", e)
print("Recorded:", len(expenses))

Standard output:

Recorded: 1
Rejected: Amount must be positive
Rejected: Category cannot be empty
Recorded: 1

…and the logging module writes INFO: Added expense of $12.50 to Food to the console's error stream (logs go there by default, so they never pollute data your program prints). Design points: validation lives at the entrance of add_expense, so invalid records can never enter the list — the second "Recorded: 1" proves both bad calls were rejected *before* appending. The message strings become part of the user experience (Rejected: Amount must be positive reads well because the exception text was written for humans). And the hierarchy pays rent immediately: code that wants to handle all tracker problems catches TrackerError; code that cares about one kind catches the specific subclass. Callers choose their precision.

Logging beats print for anything that stays

print is for the user; logging is for the record. Logging adds levels (DEBUG, INFO, WARNING, ERROR) you can filter by configuration — turn a program "loud" while debugging and "quiet" in production without editing code — and a configurable format that can include timestamps and module names when you ask for them. The logging.info("... $%.2f ...", amount, category) call above also shows the lazy-formatting idiom: pass values as arguments instead of building an f-string, so the string is only assembled if the level is actually enabled. Rule of thumb: print what the *user* asked to see; log what *you* would want to know when they report a problem at midnight. Exceptions you handle gracefully get a WARNING; ones that lose data get an ERROR plus a traceback via logging.exception(...).

finally, and what with really does

finally exists for resources that must be released whatever happens. Written out in full, file handling looks like this:

def first_line(path):
    f = None
    try:
        f = open(path, encoding="utf-8")
        return f.readline().strip()
    except FileNotFoundError:
        return "no such file"
    finally:
        if f is not None:
            f.close()
            print("File closed.")

with open("note.txt", "w", encoding="utf-8") as f:
    f.write("Remember the milk\n")
print(first_line("note.txt"))
print(first_line("missing.txt"))

Output:

File closed.
Remember the milk
no such file

The first call reveals a subtle truth: finally runs before the function's return value reaches the caller, so "File closed." prints before "Remember the milk". For the missing file, f stays None, the guard skips the close, and the friendly message returns. This manual dance is exactly what the with statement automates — with guarantees the close on every path without the boilerplate — which is why real code uses with and this example exists only to show you the machinery underneath it.

Key idea. Key idea: Catch specifically and in order; raise descriptive exceptions (your own subclasses for your own domain); reserve finally/with for guaranteed cleanup; log for the record, print for the user.

Common mistakes

  • Catching Exception and carrying on as if nothing happened. The program continues with half-updated state and fails three functions later, far from the cause. If you cannot genuinely recover, log and re-raise (a bare raise inside the handler) or let it crash loudly near the bug.
  • Using exceptions for ordinary flow. "File missing" on a first run is *expected* — Lesson 6's load-or-fresh pattern tests with try/except because that genuinely is the cleanest check, but testing if number in range(...) beats raising and catching your own error for routine validation inside a function.
  • Raising generic errors with vague messages. raise Exception("error") forces callers to guess. A named subclass plus a sentence stating the rule that was broken is the whole point of raising at all.
  • Logging sensitive data. Passwords, tokens and full personal records do not belong in logs, which are copied, stored and shared far more casually than databases.
Try it. Try it: Give describe_number a third case: "7.5". Which handler runs, and why is the message slightly unfair to the user? Improve the message for decimal input specifically — what exception type (if any) would help?

Real-World Challenge

The task. Lesson 13's API calls assumed the network behaves. Harden them: write a fetch_json helper that retries a URL up to three times, uses raise_for_status() so HTTP errors count as failures, reports each failed attempt, and returns None (a documented outcome, not a crash) when every attempt fails. Demonstrate success on a real resource and graceful failure on a non-existent one.

Worked solution.

import requests

def fetch_json(url, tries=3):
    """Fetch JSON, retrying on failure. Returns None if all tries fail."""
    for attempt in range(1, tries + 1):
        try:
            response = requests.get(url, timeout=5)
            response.raise_for_status()
            return response.json()
        except requests.RequestException as e:
            print(f"Attempt {attempt} failed: {type(e).__name__}")
    return None

todo = fetch_json("https://jsonplaceholder.typicode.com/todos/1")
print("Got:", todo["title"] if todo else "nothing")
missing = fetch_json("https://jsonplaceholder.typicode.com/todos/999999")
print("Got:", missing)

Output:

Got: delectus aut autem
Attempt 1 failed: HTTPError
Attempt 2 failed: HTTPError
Attempt 3 failed: HTTPError
Got: None

How it works. The loop gives the operation bounded chances: range(1, tries + 1) counts attempts from 1 like a human would. raise_for_status() converts silent HTTP failures (a 404 body that happens to parse as JSON {}) into real exceptions, unifying network errors, timeouts and HTTP errors under requests.RequestException — one handler covers the whole family, the specific-catch principle applied to a library's hierarchy. Returning None as the documented failure value lets the caller decide what a missing resource means (skip it? use a default? alert?), while unexpected bug-type exceptions — a typo in this code raising NameError — are *not* caught and will still crash loudly during development, exactly as they should.

Extension. Retrying instantly three times can hammer a struggling server. Add a pause that grows between attempts (time.sleep(attempt) — one second, then two) and log each attempt with logging.warning including the URL, so a production log shows the whole recovery story. Then test the pause version against the missing resource and time it: the run should take about three seconds longer, with identical results.

Going further — assertions versus exceptions

Lesson 17 will formalise testing, but one distinction belongs to this lesson: assertions and exceptions serve different masters. An assertion states an internal belief — "at this point in my own code, this must be true" — and exists to catch *programmer* errors during development. A raised exception reports a condition the caller may legitimately need to handle — bad input, a missing file, a refused operation:

def withdraw(balance, amount):
    assert amount > 0, "amount must be positive"
    assert amount <= balance, "insufficient funds"
    return balance - amount

print(withdraw(100, 30))

Output: 70. The asserts carry messages, so a violation names the broken rule in the traceback. But treat this example as a boundary marker, not a template: Python's -O flag strips assertions entirely, so any check your program's correctness *depends on* — and user-input validation above all — must be real code: an if raising a proper exception, as add_expense did in the main lesson. A useful split: assert for "this can only fail if I have a bug", raise for "this can fail because the world is messy". Contracts between your own functions lean on the first; every door facing users, files and networks needs the second.

🎬 Watch & Learn

Prefer video? These searches surface top tutorials on this lesson's topic:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. Your own exception — Define AgeError(Exception). Write set_age(n) that raises it for negative ages, and catch it in a try/except.
    HintRaising looks like raise AgeError("age cannot be negative").
  2. Log to a file — Configure logging so INFO messages go to myapp.log with timestamps, then log three events.
    Hintlogging.basicConfig(filename="myapp.log", level=logging.INFO, format=...) — see the Applied Track.
  3. Retry loop — Write code that tries a risky operation up to 3 times, stopping early on success.
    HintLoop for attempt in range(3): with try/except inside and break on success.

🌍 Real World

When a website shows "Something went wrong, we're looking into it" instead of crashing, that's exception handling plus logging doing its job. Engineers at every tech company read log files like app.log to diagnose production issues at 2 AM — your logging setup is the real workflow.

Keep reading to complete this lesson