> "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
In This Chapter
- Chapter Overview
- 11.1 Why Error Handling Matters
- 11.2 Types of Errors
- 11.3 Reading Tracebacks
- 11.4 try/except: Catching Exceptions
- 11.5 Multiple except Clauses
- 11.6 else and finally
- 11.7 Raising Exceptions
- 11.8 LBYL vs EAFP
- 11.9 Custom Exceptions
- 11.10 Anti-Patterns: What Not to Do
- 11.11 Project Checkpoint: TaskFlow v1.0
- Chapter Summary
- What's Next
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
contextlibmodule offers advanced patterns for resource management that build onfinally.
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:
- Doesn't crash on unexpected input — it tells the user what went wrong
- Doesn't lose data — it saves progress before failing
- Gives actionable feedback — "File 'grades.csv' not found in /home/user/data/" is useful; a raw traceback is not (for most users)
- 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
- Syntax error — Python catches the missing
)before running.- Runtime error —
IndexErroroccurs when the list index is out of range.- 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()andstr.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:
-
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. -
The call stack — reading upward: Each
File "...", line N, in function_nameentry 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. -
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.jsondoesn'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'. Theopen()call is on line 2 ofread_config. That function was called byload_settings(line 7), which was called bystart_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 instart_apporload_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
- The user enters non-numeric text like
"eighty, ninety"—int()raisesValueError- The user enters nothing (empty string) —
split(",")returns[""],int("")raisesValueError- The user enters extra commas like
"85,,92"—int("")raisesValueErroron the empty string between commas- If somehow
score_listends up empty —len(score_list)is 0, causingZeroDivisionErrorAny of these crashes the program. In Section 11.5, we'll see how to handle them all.
🔗 Connection to Chapter 9: Remember
KeyErrorfrom dictionaries? When you writestudent["grade"]and the key doesn't exist, Python raisesKeyError. That's an exception — and now you know how to handle it withtry/exceptinstead of always checking withif "grade" in studentfirst.
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
Exceptionis almost always too broad. CatchingBaseExceptionis even worse — it catchesKeyboardInterrupt(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
finallylike 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:
- try runs first, always.
- If an exception occurs, the matching except block runs. If no except matches, the exception propagates up (but
finallystill runs). - If no exception occurs, the else block runs.
- 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
withstatement for file handling?with open(filename) as f:is actually built on the same idea asfinally— it guarantees the file is closed when the block exits, even if an exception occurs. Under the hood,withuses 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 Cint("hello")raisesValueError, so theexceptblock prints "A". Theelseblock is skipped (it only runs when no exception occurs). Thefinallyblock 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:
-
Use the right exception type.
ValueErrorfor wrong values,TypeErrorfor wrong types,FileNotFoundErrorfor missing files. Don't use genericExceptionunless nothing else fits. -
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.
- 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 eto 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
ifchecks before every operation and start usingtry/exceptas 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:
-
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.
-
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. -
You need to check multiple conditions before a complex operation. Sometimes a series of upfront checks is clearer than nested
try/exceptblocks.
# 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 = NoneThe EAFP version is shorter and handles any type that supports division, not justint. It catchesTypeError(ifvalueisn't a number) andZeroDivisionError(ifvalueis 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 istry: num = int(user_input) except ValueError: .... The EAFP version is more robust —isdigit()doesn't handle negative numbers or floats, butint()andfloat()handle them correctly (and raiseValueErrorfor 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?
-
Clarity.
raise InvalidScoreError(...)tells readers exactly what went wrong.raise ValueError(...)is more generic. -
Selective catching. Callers can catch your specific exceptions without accidentally catching unrelated
ValueErrors from other code. -
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 exceptor catchExceptionbroadlyWrite helpful error messages with raiseRaise generic exceptions with vague messages Keep tryblocks shortWrap huge blocks of code in a single tryUse finallyfor cleanupLeave files/resources open on error Use elsefor success-only codeMix success code with error-prone code in tryLog or report exceptions Silently passon 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
- Custom exceptions:
TaskFlowError,TaskNotFoundError,InvalidPriorityError - Input validation with retry loops: Every user input is validated with
try/exceptand re-prompted on failure - File operation safety: Loading and saving handle
FileNotFoundError,json.JSONDecodeError, andPermissionError - Graceful degradation: If the tasks file is corrupted, TaskFlow starts with an empty task list and warns the user (instead of crashing)
- 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_tasksfunction above. Why does it catch three different exception types (FileNotFoundError,json.JSONDecodeError,PermissionError) instead of just catchingException?
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]raisesKeyError. 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 aboutdict.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.