22 min read

> "Errors should never pass silently. Unless explicitly silenced."

Learning Objectives

  • Read a Python traceback and identify the exception type, message, and exact line where the error occurred
  • Use try/except/else/finally to handle exceptions gracefully instead of crashing
  • Catch specific exception types and handle each one appropriately
  • Raise exceptions with meaningful messages to signal errors in your own functions
  • Compare LBYL and EAFP approaches and choose the Pythonic pattern for a given situation
  • Define simple custom exception classes for domain-specific error conditions
  • Identify and avoid common error handling anti-patterns

Chapter 11: Error Handling: When Things Go Wrong (and They Will)

"Errors should never pass silently. Unless explicitly silenced." — The Zen of Python (import this)

Chapter Overview

Your programs are going to crash. Not because you're a bad programmer — because all programs encounter unexpected situations. A user types "twelve" instead of 12. A file you need doesn't exist. A network connection drops mid-download. A list turns out to be empty when you expected it to have items. These aren't edge cases you can ignore. They're the reality of software that runs in the real world.

Up to now, when your programs hit an error, Python printed a scary-looking traceback message and stopped dead. That's fine for learning. It's not fine for software that other people use. Imagine if your banking app crashed every time you mistyped your password, or if a medical records system lost data every time a network blip occurred.

This chapter teaches you to write programs that handle errors gracefully — programs that anticipate what can go wrong, respond intelligently, and keep running when possible. You'll learn Python's exception system, master the try/except/else/finally syntax, understand when and how to raise your own exceptions, and discover a Pythonic philosophy called EAFP that will change how you think about writing robust code.

In this chapter, you will learn to: - Read Python tracebacks and extract actionable information from error messages - Use try/except to catch and handle exceptions instead of crashing - Handle multiple exception types with separate responses for each - Clean up resources with finally and use else for success-only code - Raise exceptions with meaningful messages in your own functions - Apply the EAFP principle — Python's preferred error handling philosophy - Define custom exception classes for domain-specific errors

🏃 Fast Track: If you're comfortable with what exceptions are and just want the mechanics, skim 11.1-11.2 and jump to 11.4. Come back for 11.8 (EAFP) — it's the threshold concept and the most important section.

🔬 Deep Dive: After this chapter, explore Python's full exception hierarchy in the official documentation. The contextlib module offers advanced patterns for resource management that build on finally.


11.1 Why Error Handling Matters

Let's start with a program that works perfectly — until it doesn't.

def calculate_average(scores):
    total = sum(scores)
    return total / len(scores)

scores = [85, 92, 78, 95, 88]
avg = calculate_average(scores)
print(f"Average: {avg:.1f}")

Output:

Average: 87.6

Looks great. Now what happens when someone calls it with an empty list?

scores = []
avg = calculate_average(scores)  # ZeroDivisionError: division by zero

The program crashes. Python prints a traceback, and whatever your program was doing — saving a file, processing a batch of records, running a web server — stops immediately. If Elena Vasquez's nonprofit report script (from Chapter 10) crashes halfway through processing because one county's CSV has a blank row, she loses all the work done so far. That's not just annoying — it's a bug that costs real time and trust.

Error handling is the practice of writing code that anticipates, detects, and responds to runtime errors. Good error handling means your program:

  1. Doesn't crash on unexpected input — it tells the user what went wrong
  2. Doesn't lose data — it saves progress before failing
  3. Gives actionable feedback — "File 'grades.csv' not found in /home/user/data/" is useful; a raw traceback is not (for most users)
  4. Fails gracefully — when it truly can't continue, it cleans up after itself

📊 Real-World Application: In 2012, Knight Capital Group lost $440 million in 45 minutes because of a software deployment error that wasn't properly handled. Old code was accidentally activated, and the system had no error handling to detect the anomaly and halt trading. The company nearly went bankrupt. We'll explore this case in detail in Case Study 1.


11.2 Types of Errors

Before we fix errors, we need to distinguish three fundamentally different kinds.

11.2.1 Syntax Errors (Python Won't Even Try)

A syntax error means your code violates Python's grammar rules. Python catches these before your program runs — during the parsing phase.

def greet(name)     # SyntaxError: expected ':'
    print(f"Hello, {name}")

Python points directly at the problem. You fix the syntax, and the error goes away. Syntax errors aren't really "handled" — they're fixed. Your editor's linter catches most of these before you even run the code.

11.2.2 Runtime Errors (Exceptions)

A runtime error happens while the program is running. The syntax is valid, but something goes wrong during execution. In Python, runtime errors are called exceptions.

number = int("hello")        # ValueError
print(scores[100])           # IndexError
result = 10 / 0              # ZeroDivisionError
data = open("missing.txt")   # FileNotFoundError

Each of these lines is syntactically correct — Python is happy to try running them. The problem only appears when the code actually executes with specific values. These are the errors we'll learn to handle in this chapter.

11.2.3 Logic Errors (The Sneaky Ones)

A logic error means your program runs without crashing but produces the wrong result. Python can't detect these — the code is valid and doesn't raise any exceptions. It just does the wrong thing.

def celsius_to_fahrenheit(celsius):
    return celsius * 9 / 5 - 32  # Bug: should be + 32, not - 32

print(celsius_to_fahrenheit(100))  # Prints 148.0 instead of 212.0

No crash, no error message — just a wrong answer. Logic errors are the hardest to find because nothing tells you they exist. You discover them through testing (Chapter 13) or when someone notices the output is wrong. Error handling can't help with logic errors — they're a testing and debugging problem.

🔄 Check Your Understanding (try to answer before reading on)

Classify each of the following as a syntax error, runtime error, or logic error: 1. print("Hello" (missing closing parenthesis) 2. names = ["Alice", "Bob"]; print(names[5]) 3. A program that calculates a 15% tip but accidentally uses 0.015 instead of 0.15

Verify

  1. Syntax error — Python catches the missing ) before running.
  2. Runtime errorIndexError occurs when the list index is out of range.
  3. Logic error — the program runs fine but the tip is 10x too small. No exception is raised.

🔗 Connection to Chapter 7: Remember string validation from Chapter 7? Methods like str.isdigit() and str.isalpha() let you check input before trying to convert it. That's one approach to avoiding runtime errors — but as you'll see in Section 11.8, Python often prefers a different strategy.


11.3 Reading Tracebacks

When a runtime error occurs, Python produces a traceback — a detailed report showing exactly what went wrong and where. New programmers often find tracebacks intimidating, but they're actually Python trying to help you. Learning to read them is one of the highest-leverage debugging skills you can develop.

Here's a traceback from a function that processes student grades:

def validate_score(score):
    if score > 100:
        raise ValueError(f"Score {score} exceeds maximum of 100")
    return score

def process_grades(filename):
    with open(filename) as f:
        for line in f:
            name, score_str = line.strip().split(",")
            score = int(score_str)
            validated = validate_score(score)
            print(f"{name}: {validated}")

process_grades("grades.csv")

If grades.csv contains a line like "Elena,105", Python produces:

Traceback (most recent call last):
  File "grades.py", line 13, in <module>
    process_grades("grades.csv")
  File "grades.py", line 11, in process_grades
    validated = validate_score(score)
  File "grades.py", line 3, in validate_score
    raise ValueError(f"Score {score} exceeds maximum of 100")
ValueError: Score 105 exceeds maximum of 100

11.3.1 Anatomy of a Traceback

Read tracebacks from the bottom up. Here's what each part tells you:

  1. Bottom line — the exception: ValueError: Score 105 exceeds maximum of 100. This is the what: what went wrong and (if the developer wrote a good message) why.

  2. The call stack — reading upward: Each File "...", line N, in function_name entry shows one step in the chain of function calls that led to the error. The most recent call is at the bottom (closest to the error), and the original call is at the top.

  3. The origin — top of the stack: File "grades.py", line 13, in <module> — this is where your program started the chain that eventually crashed.

🐛 Debugging Walkthrough: Multi-Level Traceback

Let's trace through a more complex example. Say you have three functions that call each other:

```python def read_config(path): with open(path) as f: data = f.read() return data

def load_settings(): config_text = read_config("settings.json") # ... parse config_text ... return config_text

def start_app(): settings = load_settings() print("App started with settings:", settings)

start_app() ```

If settings.json doesn't exist, you get:

Traceback (most recent call last): File "app.py", line 14, in <module> start_app() File "app.py", line 11, in start_app settings = load_settings() File "app.py", line 7, in load_settings config_text = read_config("settings.json") File "app.py", line 2, in read_config with open(path) as f: FileNotFoundError: [Errno 2] No such file or directory: 'settings.json'

How to read it: Start at the bottom. The error is FileNotFoundError — a file doesn't exist. The file in question is 'settings.json'. The open() call is on line 2 of read_config. That function was called by load_settings (line 7), which was called by start_app (line 11), which was called at the module level (line 14).

Key insight: The error occurs in read_config, but the fix might belong in start_app or load_settings — wherever it makes sense to handle "what if the config file is missing?"

💡 Intuition: Think of a traceback like a stack of sticky notes. Each function call adds a note to the top of the stack. When the error happens, Python shows you the entire stack so you can trace how you got there. That's why it's called a "call stack."


11.4 try/except: Catching Exceptions

Now for the main event. Python's try/except statement lets you attempt code that might fail and specify what to do if it does.

11.4.1 Basic Syntax

try:
    age = int(input("Enter your age: "))
    print(f"You'll be {age + 1} next year.")
except ValueError:
    print("That's not a valid number. Please enter digits only.")

How it works: 1. Python executes the code inside try. 2. If no exception occurs, the except block is skipped entirely. 3. If a ValueError occurs, Python jumps to the except block and executes that code instead of crashing. 4. Either way, the program continues running after the try/except block.

If the user enters 25:

Enter your age: 25
You'll be 26 next year.

If the user enters twenty-five:

Enter your age: twenty-five
That's not a valid number. Please enter digits only.

No crash. The program handles the error and keeps going.

11.4.2 A Practical Pattern: Retry Loop

One of the most common patterns is combining try/except with a loop to keep asking until the user provides valid input:

def get_positive_integer(prompt):
    """Keep asking until the user enters a valid positive integer."""
    while True:
        try:
            value = int(input(prompt))
            if value <= 0:
                print("Please enter a positive number.")
                continue
            return value
        except ValueError:
            print("That's not a valid integer. Try again.")

# Usage
num_students = get_positive_integer("How many students? ")
print(f"Processing {num_students} students...")

Sample interaction:

How many students? abc
That's not a valid integer. Try again.
How many students? -3
Please enter a positive number.
How many students? 5
Processing 5 students...

This pattern — loop + try/except + validation — is so useful that you'll reach for it constantly.

🧩 Productive Struggle

Here's a program that crashes on bad input. Before reading on, think about how you'd make it robust:

python scores = input("Enter scores separated by commas: ") score_list = [int(s) for s in scores.split(",")] average = sum(score_list) / len(score_list) print(f"Average: {average:.1f}")

What could go wrong? (Think of at least three scenarios.)

Possible failure points

  1. The user enters non-numeric text like "eighty, ninety"int() raises ValueError
  2. The user enters nothing (empty string) — split(",") returns [""], int("") raises ValueError
  3. The user enters extra commas like "85,,92"int("") raises ValueError on the empty string between commas
  4. If somehow score_list ends up empty — len(score_list) is 0, causing ZeroDivisionError

Any of these crashes the program. In Section 11.5, we'll see how to handle them all.

🔗 Connection to Chapter 9: Remember KeyError from dictionaries? When you write student["grade"] and the key doesn't exist, Python raises KeyError. That's an exception — and now you know how to handle it with try/except instead of always checking with if "grade" in student first.


11.5 Multiple except Clauses

Different errors often need different responses. You can specify multiple except clauses, each handling a different exception type:

def read_score_from_file(filename, line_number):
    """Read a specific score from a file. Each line has: name,score"""
    try:
        with open(filename) as f:
            lines = f.readlines()
        parts = lines[line_number].strip().split(",")
        score = int(parts[1])
        return score
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found.")
        return None
    except IndexError:
        print(f"Error: Line {line_number} doesn't exist in the file.")
        return None
    except ValueError:
        print(f"Error: Line {line_number} doesn't contain a valid score.")
        return None

Each exception type gets its own message and response. This is much better than a generic "something went wrong" message because it tells the user (or the developer) exactly what went wrong.

11.5.1 Catching Multiple Types in One Clause

If you want the same response for several exception types, use a tuple:

try:
    value = my_dict[key]
    result = 100 / value
except (KeyError, ZeroDivisionError, TypeError) as e:
    print(f"Could not compute result: {e}")
    result = None

The as e syntax gives you access to the exception object, which contains the error message. This is useful for logging or displaying details.

11.5.2 The Exception Hierarchy

Python's exceptions form a hierarchy. Every exception inherits from BaseException, and most exceptions you'll encounter inherit from Exception. Here's a simplified view of the most common ones:

BaseException
├── KeyboardInterrupt
├── SystemExit
└── Exception
    ├── ArithmeticError
    │   └── ZeroDivisionError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError
    │   └── FileNotFoundError
    ├── TypeError
    └── ValueError

Why this matters: If you catch LookupError, you'll catch both IndexError and KeyError because they're subclasses. Catching Exception catches almost everything. This hierarchy helps you choose the right level of specificity.

⚠️ Pitfall: Catching Exception is almost always too broad. Catching BaseException is even worse — it catches KeyboardInterrupt (Ctrl+C), which means the user can't even stop your program. Always catch the most specific exception type you can.


11.6 else and finally

The full try statement has four clauses: try, except, else, and finally. Each has a distinct role.

11.6.1 The else Clause: "If Nothing Went Wrong"

The else clause runs only if the try block completed without raising an exception:

try:
    score = int(input("Enter score: "))
except ValueError:
    print("Invalid input — not a number.")
else:
    # Only runs if int() succeeded
    if score < 0 or score > 100:
        print("Score must be between 0 and 100.")
    else:
        print(f"Recorded score: {score}")

Why not just put the else code inside try? Because you want the except clause to catch only the specific error you're anticipating (the int() conversion). If you put the validation logic inside try, any ValueError raised by that logic would also be caught — and you wouldn't realize you had a bug.

Rule of thumb: Keep the try block as short as possible. Put only the code that might raise the expected exception inside try. Put success-path code in else.

11.6.2 The finally Clause: "No Matter What"

The finally clause runs regardless of whether an exception occurred — even if the exception isn't caught, even if there's a return statement:

def read_data(filename):
    f = None
    try:
        f = open(filename)
        data = f.read()
        return data
    except FileNotFoundError:
        print(f"File '{filename}' not found.")
        return ""
    finally:
        if f is not None:
            f.close()
            print("(File handle closed.)")

The finally block is guaranteed to run. This makes it perfect for resource cleanup — closing files, releasing database connections, or restoring state. Even if an unhandled exception propagates up, finally runs first.

💡 Intuition: Think of finally like the cleanup crew after an event. Whether the event goes perfectly or the keynote speaker trips and falls off the stage, the chairs still need to be put away.

11.6.3 The Full Execution Flow

Here's how the four clauses work together. Picture this as a flowchart:

Execute try block
    │
    ├── Exception raised? ──YES──→ Match an except clause?
    │                                 │
    │                          YES────┤────NO──→ Exception propagates up
    │                            │                (finally still runs first)
    │                            ▼
    │                     Execute matching
    │                     except block
    │                            │
    │                            ▼
    │                     Execute finally ──→ Continue after block
    │
    └── No exception ──→ Execute else block
                              │
                              ▼
                        Execute finally ──→ Continue after block

In plain English:

  1. try runs first, always.
  2. If an exception occurs, the matching except block runs. If no except matches, the exception propagates up (but finally still runs).
  3. If no exception occurs, the else block runs.
  4. finally runs last, no matter what happened.
def demonstrate_flow(value):
    try:
        result = 10 / value
    except ZeroDivisionError:
        print("  except: Can't divide by zero!")
    else:
        print(f"  else: Result is {result}")
    finally:
        print("  finally: This always runs.")

print("Test 1: Normal case")
demonstrate_flow(2)
print()
print("Test 2: Error case")
demonstrate_flow(0)

Output:

Test 1: Normal case
  else: Result is 5.0
  finally: This always runs.

Test 2: Error case
  except: Can't divide by zero!
  finally: This always runs.

🔗 Connection to Chapter 10: Remember the with statement for file handling? with open(filename) as f: is actually built on the same idea as finally — it guarantees the file is closed when the block exits, even if an exception occurs. Under the hood, with uses Python's "context manager" protocol, which automates the try/finally pattern.

🔄 Check Your Understanding

Given this code: python try: x = int("hello") except ValueError: print("A") else: print("B") finally: print("C")

What does it print?

Verify

A C int("hello") raises ValueError, so the except block prints "A". The else block is skipped (it only runs when no exception occurs). The finally block always runs, printing "C".


11.7 Raising Exceptions

So far we've been catching exceptions that Python raises. But you can — and should — raise your own exceptions when your functions receive bad input or encounter invalid states.

11.7.1 The raise Statement

def calculate_grade(score):
    """Convert a numeric score (0-100) to a letter grade."""
    if not isinstance(score, (int, float)):
        raise TypeError(f"Score must be a number, got {type(score).__name__}")
    if score < 0 or score > 100:
        raise ValueError(f"Score must be 0-100, got {score}")

    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"

# Valid usage
print(calculate_grade(85))    # Output: B
print(calculate_grade(92.5))  # Output: A

# Invalid usage — these raise exceptions
# calculate_grade(105)   # ValueError: Score must be 0-100, got 105
# calculate_grade("A")   # TypeError: Score must be a number, got str

11.7.2 Why Raise Exceptions?

You raise exceptions to enforce the contract of your function. calculate_grade promises to convert scores between 0 and 100 to letter grades. If someone passes 105 or "banana", the function shouldn't silently return a wrong answer — it should loudly signal that something is wrong.

Guidelines for raising exceptions:

  1. Use the right exception type. ValueError for wrong values, TypeError for wrong types, FileNotFoundError for missing files. Don't use generic Exception unless nothing else fits.

  2. Write helpful error messages. Compare: - Bad: raise ValueError("invalid input") - Good: raise ValueError(f"Score must be 0-100, got {score}")

The good message tells the developer exactly what went wrong and what the actual value was.

  1. Raise early, catch late. Functions should validate their inputs and raise exceptions immediately if something is wrong. The caller decides how to handle the exception.
# The function raises — it doesn't decide what to do about bad input
def calculate_grade(score):
    if score < 0 or score > 100:
        raise ValueError(f"Score must be 0-100, got {score}")
    # ... grade calculation ...

# The caller handles — it decides the response
try:
    grade = calculate_grade(user_score)
    print(f"Your grade: {grade}")
except ValueError as e:
    print(f"Error: {e}")
    print("Please enter a score between 0 and 100.")

This separation of concerns is a key design principle. The function that detects the error is often not the right place to decide how to respond to it.

⚠️ Pitfall: Don't catch an exception and then raise a different one without context. If you must re-raise, use raise ... from e to preserve the original traceback:

python try: value = int(raw_input) except ValueError as e: raise ValueError(f"Invalid score in record: {raw_input}") from e


11.8 LBYL vs EAFP

This section introduces the threshold concept of this chapter — a Pythonic philosophy that may change how you think about writing code.

11.8.1 Two Philosophies

There are two fundamentally different approaches to handling potential errors:

LBYL — "Look Before You Leap" means checking for error conditions before attempting an operation:

# LBYL: Check first, then act
def get_student_grade_lbyl(students, name):
    if name in students:
        return students[name]
    else:
        return "Student not found"

students = {"Alice": "A", "Bob": "B"}
print(get_student_grade_lbyl(students, "Alice"))    # A
print(get_student_grade_lbyl(students, "Charlie"))  # Student not found

EAFP — "Easier to Ask Forgiveness than Permission" means attempting the operation and handling the exception if it fails:

# EAFP: Try it, handle failure
def get_student_grade_eafp(students, name):
    try:
        return students[name]
    except KeyError:
        return "Student not found"

students = {"Alice": "A", "Bob": "B"}
print(get_student_grade_eafp(students, "Alice"))    # A
print(get_student_grade_eafp(students, "Charlie"))  # Student not found

Both produce identical results. So why does Python prefer one over the other?

11.8.2 Why Python Prefers EAFP

🚪 Threshold Concept: EAFP

Python's culture and design strongly favor EAFP over LBYL. This feels counterintuitive at first — shouldn't you check for problems before they happen? But there are compelling reasons EAFP is preferred:

Before this clicks: "I should always check if something is valid before trying to use it." After this clicks: "In Python, I should try the operation and handle the exception. The code is cleaner, often faster, and avoids race conditions."

This threshold concept changes how you write Python. Once you internalize it, you'll stop writing defensive if checks before every operation and start using try/except as a normal control flow tool.

Here's a comparison that shows why:

Factor LBYL EAFP
Code readability Extra if checks clutter the "happy path" Happy path is clear; error handling is separate
Performance (common case) Always pays the cost of checking No overhead when the operation succeeds
Race conditions Check-then-act has a gap: conditions can change between the check and the action No gap — you attempt the operation atomically
Multiple failure modes Requires checking every possible problem upfront Catches whatever actually goes wrong
Python convention Less Pythonic More Pythonic — endorsed by Python core developers

11.8.3 The Race Condition Problem

The race condition argument is the strongest case for EAFP. Consider checking whether a file exists before opening it:

# LBYL — has a race condition
import os

if os.path.exists("data.csv"):
    # Between this check and the next line, another program
    # could delete the file!
    with open("data.csv") as f:
        data = f.read()
else:
    print("File not found.")

# EAFP — no race condition
try:
    with open("data.csv") as f:
        data = f.read()
except FileNotFoundError:
    print("File not found.")

In the LBYL version, the file could be deleted between the os.path.exists() check and the open() call. It's rare, but in concurrent systems (web servers, multi-threaded applications), it happens. The EAFP version doesn't have this problem — the open() either works or it doesn't.

11.8.4 When LBYL Is Still Appropriate

EAFP isn't always the right choice. LBYL is better when:

  1. The check is cheap and the operation is expensive. If parsing a 2 GB file will take 30 seconds, check that the file exists first.

  2. The error case isn't truly exceptional. If you're validating user input in a form and most users get it wrong the first time, that's not an "exception" — it's the normal case. Use if/else.

  3. You need to check multiple conditions before a complex operation. Sometimes a series of upfront checks is clearer than nested try/except blocks.

# LBYL makes sense here — validating before an expensive operation
def process_large_file(filepath, output_dir):
    if not os.path.exists(filepath):
        print(f"Input file '{filepath}' not found.")
        return
    if not os.path.isdir(output_dir):
        print(f"Output directory '{output_dir}' not found.")
        return
    if os.path.getsize(filepath) == 0:
        print("Input file is empty.")
        return

    # Now do the expensive processing...
    print(f"Processing {filepath}...")

The key insight: EAFP is the default in Python. Use LBYL when you have a specific reason to prefer it.

🔄 Check Your Understanding

Convert this LBYL code to EAFP: python if isinstance(value, int) and value != 0: result = 100 / value else: result = None

Verify

python try: result = 100 / value except (TypeError, ZeroDivisionError): result = None The EAFP version is shorter and handles any type that supports division, not just int. It catches TypeError (if value isn't a number) and ZeroDivisionError (if value is 0).

🔗 Spaced Review — Chapter 7 (Strings): In Chapter 7, you learned string methods like str.isdigit() for validating input. That's a LBYL approach: if user_input.isdigit(): num = int(user_input). The EAFP equivalent is try: num = int(user_input) except ValueError: .... The EAFP version is more robust — isdigit() doesn't handle negative numbers or floats, but int() and float() handle them correctly (and raise ValueError for truly invalid input).


11.9 Custom Exceptions

Python's built-in exceptions cover most situations, but sometimes you need exceptions that are specific to your application's domain.

11.9.1 Defining a Custom Exception

A custom exception is just a class that inherits from Exception:

class InvalidScoreError(Exception):
    """Raised when a score is outside the valid range (0-100)."""
    pass

class StudentNotFoundError(Exception):
    """Raised when a student lookup fails."""
    pass

def record_score(gradebook, student_name, score):
    if student_name not in gradebook:
        raise StudentNotFoundError(
            f"No student named '{student_name}' in the gradebook"
        )
    if not 0 <= score <= 100:
        raise InvalidScoreError(
            f"Score must be 0-100, got {score}"
        )
    gradebook[student_name] = score

# Usage
gradebook = {"Alice": None, "Bob": None}
try:
    record_score(gradebook, "Alice", 95)
    record_score(gradebook, "Charlie", 85)
except StudentNotFoundError as e:
    print(f"Student error: {e}")
except InvalidScoreError as e:
    print(f"Score error: {e}")

Output:

Student error: No student named 'Charlie' in the gradebook

11.9.2 Why Use Custom Exceptions?

  1. Clarity. raise InvalidScoreError(...) tells readers exactly what went wrong. raise ValueError(...) is more generic.

  2. Selective catching. Callers can catch your specific exceptions without accidentally catching unrelated ValueErrors from other code.

  3. Hierarchy. You can create a base exception for your application and derive specific ones from it:

class TaskFlowError(Exception):
    """Base exception for all TaskFlow errors."""
    pass

class TaskNotFoundError(TaskFlowError):
    """Raised when a task ID doesn't exist."""
    pass

class InvalidPriorityError(TaskFlowError):
    """Raised when a priority value is invalid."""
    pass

# Catch all TaskFlow errors at once, or specific ones
try:
    # ... TaskFlow operations ...
    pass
except TaskFlowError as e:
    print(f"TaskFlow error: {e}")

💡 Intuition: Custom exceptions are like custom road signs. A generic "Warning" sign tells you something is wrong, but "Bridge Out Ahead" tells you exactly what the problem is and helps you decide what to do about it.


11.10 Anti-Patterns: What Not to Do

Now that you know how to use error handling, let's talk about how people misuse it. These are patterns experienced developers see regularly in beginner code — and occasionally in code that should know better.

11.10.1 The Bare except (Never Do This)

# ANTI-PATTERN: bare except catches EVERYTHING
try:
    result = process_data(data)
except:  # Catches KeyboardInterrupt, SystemExit, MemoryError...
    print("Something went wrong.")

A bare except catches every exception, including KeyboardInterrupt (the user pressing Ctrl+C) and SystemExit (the program trying to quit). This makes your program nearly impossible to stop and hides real bugs. Always specify the exception type.

11.10.2 Catching Too Broadly

# ANTI-PATTERN: Exception is too broad
try:
    user_data = load_user(user_id)
    report = generate_report(user_data)
    send_email(report, user_data["email"])
except Exception as e:
    print(f"Error: {e}")

If send_email crashes because of a typo in your code (a NameError), this catches it and prints a vague message. You won't realize there's a bug — you'll think it's a user data problem. Catch specific exceptions for specific operations.

11.10.3 Silencing Errors

# ANTI-PATTERN: silently ignoring errors
try:
    save_to_database(record)
except Exception:
    pass  # "It's fine." (Narrator: It was not fine.)

If the database save fails, the program continues as if nothing happened — but the data is lost. The user thinks their data was saved. This is how data corruption happens.

If you genuinely need to ignore an exception (rare, but it happens), document why:

try:
    os.remove(temp_file)
except FileNotFoundError:
    pass  # File already deleted — that's fine, we wanted it gone anyway

11.10.4 Using Exceptions for Flow Control

# ANTI-PATTERN: using exceptions as if/else
def is_integer(value):
    try:
        int(value)
        return True
    except ValueError:
        return False

This one is debatable — some Pythonistas consider it acceptable because it's EAFP. But in general, exceptions should signal exceptional conditions, not normal program logic. If you expect most inputs to fail this check, a straightforward if test is clearer.

The dividing line: if the exception case is rare (a user occasionally mistyping), EAFP is fine. If it's the common case (validating every input in a batch), consider LBYL.

✅ Best Practice Summary:

Do Don't
Catch specific exception types Use bare except or catch Exception broadly
Write helpful error messages with raise Raise generic exceptions with vague messages
Keep try blocks short Wrap huge blocks of code in a single try
Use finally for cleanup Leave files/resources open on error
Use else for success-only code Mix success code with error-prone code in try
Log or report exceptions Silently pass on exceptions

11.11 Project Checkpoint: TaskFlow v1.0

It's time for a milestone. In Chapter 10, you added file persistence to TaskFlow — the app saves and loads tasks using JSON. But what happens when:

  • The JSON file is corrupted?
  • The user enters a priority that isn't "high", "medium", or "low"?
  • The user tries to delete a task number that doesn't exist?
  • The user types "abc" when asked for a task number?
  • The tasks file is accidentally deleted while the program is running?

TaskFlow v0.9 would crash on any of these. TaskFlow v1.0 handles them all gracefully. The "1.0" version number is significant — in software development, v1.0 traditionally means "ready for real users." And real users will find every possible way to break your program.

What v1.0 Adds

  1. Custom exceptions: TaskFlowError, TaskNotFoundError, InvalidPriorityError
  2. Input validation with retry loops: Every user input is validated with try/except and re-prompted on failure
  3. File operation safety: Loading and saving handle FileNotFoundError, json.JSONDecodeError, and PermissionError
  4. Graceful degradation: If the tasks file is corrupted, TaskFlow starts with an empty task list and warns the user (instead of crashing)
  5. Clean exit: KeyboardInterrupt (Ctrl+C) is caught at the top level, saving tasks before exiting

Here's the core error handling strategy:

import json
import os
from datetime import datetime

# --- Custom Exceptions ---
class TaskFlowError(Exception):
    """Base exception for TaskFlow application errors."""
    pass

class TaskNotFoundError(TaskFlowError):
    """Raised when a task ID is not found."""
    pass

class InvalidPriorityError(TaskFlowError):
    """Raised when a priority value is invalid."""
    pass

VALID_PRIORITIES = ("high", "medium", "low")
TASKS_FILE = "tasks.json"

# --- File Operations (EAFP style) ---
def load_tasks(filepath):
    """Load tasks from JSON file. Returns empty list if file missing/corrupt."""
    try:
        with open(filepath) as f:
            tasks = json.load(f)
        print(f"Loaded {len(tasks)} task(s) from {filepath}.")
        return tasks
    except FileNotFoundError:
        print("No saved tasks found. Starting fresh.")
        return []
    except json.JSONDecodeError:
        print(f"Warning: '{filepath}' is corrupted. Starting with empty task list.")
        # Back up the corrupted file so data isn't lost
        backup_name = filepath + ".corrupt"
        try:
            os.rename(filepath, backup_name)
            print(f"  Corrupted file backed up as '{backup_name}'.")
        except OSError:
            pass
        return []
    except PermissionError:
        print(f"Error: No permission to read '{filepath}'.")
        return []

def save_tasks(tasks, filepath):
    """Save tasks to JSON file with error handling."""
    try:
        with open(filepath, "w") as f:
            json.dump(tasks, f, indent=2)
    except PermissionError:
        print(f"Error: Cannot write to '{filepath}'. Check file permissions.")
    except OSError as e:
        print(f"Error saving tasks: {e}")

# --- Input Helpers ---
def get_valid_priority():
    """Prompt until user enters a valid priority."""
    while True:
        priority = input("Priority (high/medium/low): ").strip().lower()
        if priority in VALID_PRIORITIES:
            return priority
        print(f"Invalid priority '{priority}'. Choose: high, medium, or low.")

def get_task_number(max_number):
    """Prompt until user enters a valid task number."""
    while True:
        try:
            num = int(input(f"Task number (1-{max_number}): "))
            if 1 <= num <= max_number:
                return num
            print(f"Please enter a number between 1 and {max_number}.")
        except ValueError:
            print("That's not a valid number. Try again.")

# --- Task Operations ---
def add_task(tasks):
    """Add a new task with validation."""
    description = input("Task description: ").strip()
    if not description:
        print("Task description cannot be empty.")
        return
    priority = get_valid_priority()
    task = {
        "description": description,
        "priority": priority,
        "created": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "done": False,
    }
    tasks.append(task)
    print(f"Added: '{description}' ({priority} priority)")

def list_tasks(tasks):
    """Display all tasks."""
    if not tasks:
        print("No tasks yet. Add one with option 1.")
        return
    print(f"\n{'#':<4} {'Description':<30} {'Priority':<10} {'Done':<6}")
    print("-" * 54)
    for i, task in enumerate(tasks, 1):
        status = "Yes" if task["done"] else "No"
        print(f"{i:<4} {task['description']:<30} {task['priority']:<10} {status:<6}")
    print()

def delete_task(tasks):
    """Delete a task by number with validation."""
    if not tasks:
        print("No tasks to delete.")
        return
    list_tasks(tasks)
    num = get_task_number(len(tasks))
    removed = tasks.pop(num - 1)
    print(f"Deleted: '{removed['description']}'")

def toggle_done(tasks):
    """Mark a task as done/not done."""
    if not tasks:
        print("No tasks available.")
        return
    list_tasks(tasks)
    num = get_task_number(len(tasks))
    tasks[num - 1]["done"] = not tasks[num - 1]["done"]
    status = "done" if tasks[num - 1]["done"] else "not done"
    print(f"'{tasks[num - 1]['description']}' marked as {status}.")

# --- Main Menu ---
def main():
    """Main application loop with top-level error handling."""
    tasks = load_tasks(TASKS_FILE)

    menu = """
TaskFlow v1.0
  1. Add task
  2. List tasks
  3. Delete task
  4. Toggle done
  5. Save & quit
"""
    try:
        while True:
            print(menu)
            choice = input("Choose (1-5): ").strip()
            if choice == "1":
                add_task(tasks)
            elif choice == "2":
                list_tasks(tasks)
            elif choice == "3":
                delete_task(tasks)
            elif choice == "4":
                toggle_done(tasks)
            elif choice == "5":
                save_tasks(tasks, TASKS_FILE)
                print("Tasks saved. Goodbye!")
                break
            else:
                print("Invalid choice. Enter a number 1-5.")
    except KeyboardInterrupt:
        print("\n\nInterrupted! Saving tasks before exit...")
        save_tasks(tasks, TASKS_FILE)
        print("Tasks saved. Goodbye!")

if __name__ == "__main__":
    main()

What Makes This v1.0

Notice how every user interaction is wrapped in validation, every file operation has error handling, and the application degrades gracefully instead of crashing. This is what "production-ready" error handling looks like for a CLI application.

Key patterns used: - EAFP for file operations: Try to open/read, catch specific exceptions - Retry loops for input: Keep asking until the user provides valid input - Custom exceptions: Domain-specific error classes (though in this simple version, they're defined for future use as TaskFlow grows) - Top-level KeyboardInterrupt handler: Ensures data is saved even on Ctrl+C - Graceful degradation: Corrupted file? Start fresh. Missing file? Start fresh. Permission error? Tell the user.

🔄 Check Your Understanding

Look at the load_tasks function above. Why does it catch three different exception types (FileNotFoundError, json.JSONDecodeError, PermissionError) instead of just catching Exception?

Verify

Each exception type represents a different problem that requires a different response: - FileNotFoundError: Normal on first run — just start with an empty list. - json.JSONDecodeError: The file exists but is corrupted — back it up and start fresh. - PermissionError: The file exists but the OS won't let us read it — this might be a real problem the user needs to fix.

If we caught Exception, we'd handle all three the same way and couldn't provide specific, helpful feedback for each situation.


Chapter Summary

Error handling transforms your programs from fragile scripts that crash at the first unexpected input into robust applications that handle adversity gracefully. Here's what you've learned:

  • Three types of errors: Syntax errors (caught before running), runtime errors/exceptions (caught during running), and logic errors (never caught automatically)
  • Tracebacks tell you exactly what went wrong and where — read them from the bottom up
  • try/except catches exceptions and lets your program respond instead of crashing
  • Multiple except clauses let you handle different errors differently
  • else runs only on success; finally runs no matter what — use it for cleanup
  • raise lets you signal errors in your own functions with meaningful messages
  • EAFP ("Easier to Ask Forgiveness than Permission") is Python's preferred approach: try the operation and handle failure, rather than checking every precondition upfront
  • Custom exceptions give your application domain-specific error types
  • Anti-patterns to avoid: bare except, catching too broadly, silencing errors

The theme of this chapter is also a recurring theme of this entire course: errors are not failures — they are information. Every traceback is Python trying to help you. Every exception is a signal about what went wrong. Your job isn't to prevent all errors (that's impossible) — it's to handle them intelligently.

Spaced Review — Chapter 9 (Dicts): In Chapter 9, you learned that accessing a missing key with my_dict[key] raises KeyError. Now you know two ways to handle this: LBYL (if key in my_dict) or EAFP (try: value = my_dict[key] except KeyError:). You also know about dict.get(key, default), which is a third approach — a built-in method that avoids the exception entirely. All three are valid; choose based on context.


What's Next

In Chapter 12, you'll learn to organize your code into modules and packages — splitting a growing program like TaskFlow into separate files that can be imported and reused. Then in Chapter 13, you'll combine error handling with testing and debugging to write code that you can prove works correctly.

The error handling skills from this chapter are prerequisites for Chapter 13, where you'll write tests that deliberately trigger exceptions to verify your error handling actually works. The test-driven approach to software development begins by anticipating errors — exactly what you practiced here.