Skip to main content

Chapter 54: Error Handling and Exceptions

James loads a JSON file full of SmartNotes data. The first ten notes parse perfectly: title, body, word count, tags. Then the program crashes. He stares at the traceback. The eleventh note has "word_count": "many" instead of a number. One broken value in one record, and the entire program stops dead.

Emma looks at the screen. "What happens when data arrives broken?"

James thinks about it. Every function he has written so far assumes the input is valid. categorize_note assumes word_count is an integer. format_note_summary assumes title is a non-empty string. When those assumptions hold, everything works. When they break, Python raises an exception and the program dies.

"You cannot control what data comes in," Emma says. "You can only control what your program does when the data is wrong. That is what this chapter teaches."

Why Error Handling Matters for Specification

In Chapter 49, you wrote function contracts: name, parameters, return type, docstring. Those contracts describe what a function does when everything goes right. Error handling describes what a function does when something goes wrong. A robust load_notes_from_json does not just return a list of notes; it also explains what happens when the file is missing, the JSON is malformed, or a required field contains the wrong type.

Testing becomes richer too. In Chapter 52, you used pytest.raises to verify that functions raise the right exceptions. Now you will write the code that raises those exceptions and the code that catches them. Every raise you write gets a pytest.raises test. Every except you write gets a test that triggers the error path.

What You Will Learn

By the end of this chapter, you will be able to:

  • Catch exceptions with try/except/else and inspect error messages with as e
  • Raise your own exceptions with meaningful, f-string-formatted messages
  • Navigate the built-in exception hierarchy and choose the right exception type
  • Use finally for guaranteed cleanup and with for safe resource handling
  • Write manual validation functions that check every field of a data structure

Chapter Lessons

LessonTitleWhat You DoDuration
1Catching Errors with try/exceptWrite try/except blocks, use as e, add else clauses, avoid bare except25 min
2Raising Your Own ExceptionsRaise ValueError and TypeError with meaningful messages, test every raise20 min
3The Exception Hierarchy and Best PracticesNavigate built-in exceptions, use decision tables, understand finally20 min
4Resource Safety: finally and withOpen files with with, load JSON, handle FileNotFoundError and JSONDecodeError20 min
5Manual Validation PainWrite a 30+ line validation function, feel the repetition, preview Pydantic20 min
6Chapter 54 Quiz50 scenario-based questions covering all error handling concepts25 min

PRIMM-AI+ in This Chapter

Every lesson includes a PRIMM-AI+ Practice section following the five-stage cycle from Chapter 42. This is Phase 3: you are now WRITING error handling code, building on the testing patterns (Chapter 52) and function contracts (Chapter 49) you already own.

StageWhat You DoWhat It Builds
Predict [AI-FREE]Predict which except block catches an error, or whether a raise triggers, with a confidence score (1-5)Calibrates your exception-flow intuition
RunExecute the code or run pytest, compare to your predictionCreates the feedback loop
InvestigateWrite a trace artifact explaining how Python matched the exception to the handlerMakes your error-handling reasoning visible
ModifyChange the exception type or the input and predict the new behaviorTests whether your understanding transfers
Make [Mastery Gate]Write an error-handling function from scratch with tests for every pathProves you can handle errors independently

Syntax Card: Chapter 54

Reference this card while working through the lessons. Every construct shown here appears in at least one lesson.

# -- try/except/else/finally ---------------------------------
try:
value: int = int(user_input)
except ValueError as e:
print(f"Bad input: {e}")
else:
print(f"Parsed: {value}") # Runs only if no exception
finally:
print("Always runs") # Cleanup, always executes

# -- raise ---------------------------------------------------
raise ValueError("word_count must be an integer")
raise TypeError(f"Expected str, got {type(value).__name__}")

# -- Exception decision table --------------------------------
# ValueError : right type, wrong value (int("hello"))
# TypeError : wrong type entirely (len(42))
# KeyError : missing dictionary key (d["missing"])
# FileNotFoundError : file does not exist (open("nope.txt"))

# -- with open (context manager) -----------------------------
with open("notes.json") as f:
data = json.load(f)
# File is automatically closed, even if json.load crashes

# -- json.load -----------------------------------------------
import json

with open("notes.json") as f:
notes: list[dict[str, str]] = json.load(f)

Prerequisites

Before starting this chapter, you should be able to:

  • Write if/elif/else branches and for loops (Chapter 50)
  • Define and use @dataclass types with typed fields (Chapter 51)
  • Write tests with assert, pytest.raises, and pytest.approx (Chapter 52)
  • Iterate on AI-generated code using review-test-refine cycles (Chapter 53)
  • Read raise statements in AI-generated code (Chapter 52 Lesson 4 preview)

The SmartNotes Connection

At the end of this chapter, you will tackle the pain of manual validation using SmartNotes data. You will write two functions that bring together everything from Lessons 1 through 4:

  • load_notes_from_json(path: str) -> list[dict[str, object]]: Opens a JSON file with with open, catches FileNotFoundError and json.JSONDecodeError, and returns the parsed data or raises a clear error.
  • validate_note_data(data: dict[str, object]) -> Note: Checks six fields (title, body, word_count, author, tags, is_draft) with isinstance and manual bounds checking, raising TypeError or ValueError for every invalid field.

The validation function will be painfully long. You will check isinstance for every field, validate string lengths, confirm non-negative integers, and verify list contents. Thirty lines of repetitive checking for just six fields. That pain is intentional. In Chapter 55, Pydantic replaces those thirty lines with six field declarations. But you need to feel the problem before the solution makes sense.