Chapter 13: Error Handling — try, except, finally
Every program you've written so far assumes the best. The file exists. The user types a number when you ask for one. The network is up. The data is clean.
Reality is messier. Files get deleted. Users type letters when you expect numbers. Servers go down. Data arrives corrupted. In the real world, things go wrong constantly — and the programs that survive are the ones written to handle it.
Python gives you a clean, readable way to deal with errors: the try / except block. Instead of letting your program crash, you catch the error, deal with it, and keep going — or fail gracefully with a useful message.
This is one of the most important chapters in the book. Error handling is what separates code that works in a demo from code that works in production.
Why Programs Crash
When Python encounters a problem it can't resolve, it raises an exception — an error object that describes what went wrong. If nothing catches it, the program stops and prints a traceback.
You've already seen several:
print(10 / 0)
# ZeroDivisionError: division by zero
int("hello")
# ValueError: invalid literal for int() with base 10: 'hello'
name = "Alice"
print(name[100])
# IndexError: string index out of range
d = {"a": 1}
print(d["b"])
# KeyError: 'b'
open("missing_file.txt")
# FileNotFoundError: [Errno 2] No such file or directory: 'missing_file.txt'
Each error has a type (the class name before the colon) and a message (what comes after). Both give you information about what went wrong and where.
The key insight: exceptions are not disasters. They're Python's way of saying "I hit a situation I can't handle on my own. Can you?" With try / except, you can.
try and except — The Basic Pattern
Wrap the risky code in a try block. If an exception occurs, execution jumps immediately to the matching except block. If no exception occurs, the except block is skipped.
try:
number = int(input("Enter a number: "))
print(f"You entered: {number}")
except ValueError:
print("That's not a valid number. Please try again.")
print("Program continues.")
Sample run 1 — user types 42:
Enter a number: 42
You entered: 42
Program continues.
Sample run 2 — user types hello:
Enter a number: hello
That's not a valid number. Please try again.
Program continues.
Without the try / except, the second run would crash with a traceback and never reach the last line. With it, the program handles the problem and keeps going.
The flow:
- Python tries the
tryblock line by line. - If an exception occurs, it stops there and jumps to the matching
except. - If no exception occurs, it skips all
exceptblocks. - Either way, code after the
try/exceptcontinues running.
Common Exception Types
Python has dozens of built-in exception types. Here are the ones you'll encounter most:
| Exception | When it occurs |
|---|---|
ValueError |
Wrong type of value (e.g. int("hello")) |
TypeError |
Wrong type entirely (e.g. "hi" + 5) |
ZeroDivisionError |
Division or modulo by zero |
IndexError |
List/string index out of range |
KeyError |
Dictionary key doesn't exist |
FileNotFoundError |
File or directory doesn't exist |
PermissionError |
No permission to read/write a file |
AttributeError |
Object doesn't have that attribute/method |
NameError |
Variable or function name not defined |
RecursionError |
Too many nested function calls |
StopIteration |
Iterator is exhausted |
OverflowError |
Number too large for a float |
MemoryError |
Not enough memory |
OSError |
General operating system error (parent of FileNotFoundError, PermissionError, etc.) |
Catching Specific Exceptions
Catch specific exception types so you can respond appropriately to each situation.
def safe_divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero.")
return None
except TypeError:
print(f"Error: Cannot divide {type(a).__name__} by {type(b).__name__}.")
return None
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # Error: Cannot divide by zero. -> None
print(safe_divide(10, "x")) # Error: Cannot divide int by str. -> None
Catching multiple exceptions in one block
try:
value = int(input("Enter a number: "))
result = 100 / value
print(f"100 / {value} = {result}")
except (ValueError, ZeroDivisionError):
print("Please enter a non-zero integer.")
Use a tuple of exception types when the same response is appropriate for multiple errors.
Catching all exceptions with Exception
try:
risky_operation()
except Exception as e:
print(f"Something went wrong: {e}")
Exception is the base class for almost all built-in exceptions. Catching it handles anything unexpected. The as e gives you access to the exception object so you can print its message.
Important: Be specific when you can. Catching Exception too broadly can hide bugs — you catch an error you didn't expect and your program silently does the wrong thing.
# Too broad — catches everything, even bugs you didn't anticipate
try:
process_data(data)
except Exception:
pass # <- never do this silently
# Better — catch what you expect, let unexpected errors propagate
try:
process_data(data)
except ValueError as e:
print(f"Bad data: {e}")
except FileNotFoundError:
print("Data file missing.")
The as Keyword — Accessing the Exception Object
Add as e (or any name) after the exception type to capture the exception object itself.
try:
with open("data.txt", "r") as file:
content = file.read()
except FileNotFoundError as e:
print(f"File error: {e}")
print(f"Error type: {type(e).__name__}")
Output:
File error: [Errno 2] No such file or directory: 'data.txt'
Error type: FileNotFoundError
The exception object's string representation is its message. type(e).__name__ gives you the class name as a string.
else — Runs Only When No Exception Occurred
The else block runs if the try block completed without raising any exception.
try:
number = int(input("Enter a number: "))
except ValueError:
print("That's not a number.")
else:
# Only runs if no ValueError was raised
print(f"Success! You entered {number}.")
squared = number ** 2
print(f"Its square is {squared}.")
Sample run — user types 7:
Enter a number: 7
Success! You entered 7.
Its square is 49.
Sample run — user types abc:
Enter a number: abc
That's not a number.
Why use else instead of just putting the code at the end of try? Because any exception inside else won't be caught by the except blocks above it — which is usually what you want. The try block should only contain the risky code, not the code that depends on success.
finally — Always Runs
The finally block runs no matter what — whether an exception occurred or not, whether it was caught or not. It's for cleanup code that must always execute.
def read_file(filename):
file = None
try:
file = open(filename, "r")
content = file.read()
return content
except FileNotFoundError:
print(f"File '{filename}' not found.")
return None
finally:
if file:
file.close()
print("File closed.") # always runs
With with open(...), you usually don't need finally for files — the context manager handles it. But finally is essential for:
- Closing database connections
- Releasing locks
- Cleaning up temporary files
- Logging that an operation ended (regardless of success)
import time
def timed_operation():
start = time.time()
try:
print("Starting operation...")
result = some_risky_function()
return result
except Exception as e:
print(f"Operation failed: {e}")
return None
finally:
elapsed = time.time() - start
print(f"Operation took {elapsed:.3f} seconds.") # always prints
The full structure:
try:
# risky code
except SomeError:
# handle specific error
except AnotherError as e:
# handle another error
else:
# runs only if no exception
finally:
# always runs
You don't need all four every time. Use what the situation calls for.
raise — Raising Your Own Exceptions
You can raise exceptions yourself using the raise keyword. This is how you signal that something has gone wrong in your own code.
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}.")
if age < 0 or age > 150:
raise ValueError(f"Age {age} is out of the valid range (0--150).")
return age
try:
set_age(25) # fine
set_age(-5) # raises ValueError
except ValueError as e:
print(f"Invalid age: {e}")
Output:
Invalid age: Age -5 is out of the valid range (0--150).
Raising exceptions from your own functions is how you enforce rules. Instead of returning None and hoping the caller checks, you raise an exception that forces the caller to deal with the problem.
Re-raising an exception
Sometimes you want to catch an exception, do something (like log it), and then let it propagate as if you never caught it.
import logging
def process_payment(amount):
try:
result = charge_card(amount)
return result
except Exception as e:
logging.error(f"Payment failed for amount {amount}: {e}")
raise # re-raise the same exception
raise with no arguments inside an except block re-raises the current exception. The caller will still see the exception — you just got a chance to log it first.
Creating Custom Exceptions
For larger programs, you'll want your own exception classes. This lets callers catch your specific errors separately from generic Python errors.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the account balance."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw ${amount:.2f}. Balance is only ${balance:.2f}."
)
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self.balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return amount
account = BankAccount("Alice", 100.0)
try:
account.withdraw(150.0)
except InsufficientFundsError as e:
print(f"Transaction declined: {e}")
print(f" Balance: ${e.balance:.2f}")
print(f" Requested: ${e.amount:.2f}")
except ValueError as e:
print(f"Invalid amount: {e}")
Output:
Transaction declined: Cannot withdraw $150.00. Balance is only $100.00.
Balance: $100.00
Requested: $150.00
Custom exceptions carry extra information (here, balance and amount) that callers can use to respond intelligently. You'll see this pattern in every serious Python library.
Exception Hierarchy
Python exceptions form a hierarchy. Every exception ultimately inherits from BaseException. Most inherit from Exception.
BaseException
├── SystemExit <- sys.exit() raises this
├── KeyboardInterrupt <- Ctrl+C raises this
└── Exception <- catch this for normal errors
├── ValueError
├── TypeError
├── OSError
│ ├── FileNotFoundError
│ └── PermissionError
├── IndexError
├── KeyError
├── ZeroDivisionError
├── AttributeError
├── NameError
└── ... (many more)
When you catch a parent class, you catch all its children too:
try:
open("missing.txt")
except OSError:
print("An OS error occurred.") # catches FileNotFoundError too
This matters when you want to handle a family of errors together without listing every possible subtype.
Practical Patterns
Pattern 1: Retry loop
Keep trying until the user gives valid input:
def get_integer(prompt, min_val=None, max_val=None):
"""Keep asking until the user enters a valid integer in the given range."""
while True:
try:
value = int(input(prompt))
except ValueError:
print("Please enter a whole number.")
continue
if min_val is not None and value < min_val:
print(f"Please enter a number >= {min_val}.")
elif max_val is not None and value > max_val:
print(f"Please enter a number <= {max_val}.")
else:
return value
age = get_integer("Enter your age: ", min_val=0, max_val=120)
print(f"Your age is {age}.")
Sample run:
Enter your age: abc
Please enter a whole number.
Enter your age: -5
Please enter a number >= 0.
Enter your age: 200
Please enter a number <= 120.
Enter your age: 28
Your age is 28.
Pattern 2: Try multiple approaches
def load_config(primary="config.json", fallback="config_default.json"):
"""Try the primary config, fall back to the default if missing."""
for path in (primary, fallback):
try:
with open(path) as f:
print(f"Loaded config from: {path}")
return f.read()
except FileNotFoundError:
print(f"Not found: {path}")
raise FileNotFoundError("No config file found.")
Pattern 3: Context manager for cleanup
from contextlib import suppress
# suppress is a context manager that silently ignores specific exceptions
with suppress(FileNotFoundError):
import os
os.remove("temp_file.txt") # no crash if file doesn't exist
contextlib.suppress is a clean alternative to a full try / except / pass when you genuinely want to ignore a specific error.
Best Practices
1. Be specific — catch only what you expect.
# Bad
try:
result = process(data)
except Exception:
pass
# Good
try:
result = process(data)
except ValueError as e:
log_error(e)
result = default_value
2. Never silently swallow exceptions.
# Bad — hides bugs
except Exception:
pass
# Good — at minimum, log it
except Exception as e:
logger.error(f"Unexpected error: {e}")
raise
3. Keep try blocks short. Only wrap the line(s) that might fail, not the entire function.
# Bad — too broad
try:
data = load_file(path)
processed = transform(data)
save_result(processed)
except Exception:
print("Something failed.")
# Good — specific
try:
data = load_file(path)
except FileNotFoundError:
print(f"File not found: {path}")
return
processed = transform(data)
save_result(processed)
4. Use finally for cleanup, not just error handling.
5. Create custom exceptions for your domain logic. Generic ValueError is fine for basic validation. For complex business rules, a named exception makes your code self-documenting.
Putting It All Together: A Robust CSV Loader
import csv
from pathlib import Path
class CSVError(Exception):
"""Raised when a CSV file cannot be loaded or parsed."""
def load_students(filepath):
"""
Load student data from a CSV file.
Returns a list of dicts with 'name', 'grade', 'city' keys.
Raises CSVError if the file can't be read or has bad data.
"""
path = Path(filepath)
if not path.exists():
raise CSVError(f"File not found: {filepath}")
if path.suffix.lower() != ".csv":
raise CSVError(f"Expected a .csv file, got: {path.suffix}")
students = []
try:
with open(path, "r", encoding="utf-8") as file:
reader = csv.DictReader(file)
required_fields = {"name", "grade", "city"}
if not required_fields.issubset(reader.fieldnames or set()):
raise CSVError(
f"CSV missing required columns. "
f"Expected: {required_fields}, "
f"Got: {set(reader.fieldnames or [])}"
)
for line_num, row in enumerate(reader, start=2):
try:
grade = float(row["grade"])
except ValueError:
raise CSVError(
f"Line {line_num}: invalid grade '{row['grade']}' "
f"for student '{row['name']}'."
)
students.append({
"name": row["name"].strip(),
"grade": grade,
"city": row["city"].strip(),
})
except PermissionError:
raise CSVError(f"Permission denied: cannot read {filepath}")
return students
def print_report(students):
"""Print a formatted student report."""
print(f"{'Name':<15} {'Grade':>6} {'City':<12}")
print("-" * 35)
for s in students:
print(f"{s['name']:<15} {s['grade']:>6.1f} {s['city']:<12}")
grades = [s["grade"] for s in students]
print("-" * 35)
print(f"{'Average':<15} {sum(grades)/len(grades):>6.1f}")
print(f"{'Highest':<15} {max(grades):>6.1f}")
print(f"{'Lowest':<15} {min(grades):>6.1f}")
# Main program
try:
students = load_students("students.csv")
print_report(students)
except CSVError as e:
print(f"Could not load student data:\n {e}")
except Exception as e:
print(f"Unexpected error: {e}")
raise
A real-world pattern: a custom exception, specific error messages at each failure point, short try blocks, and a clean separation between error handling and program logic.
What You Learned in This Chapter
- Exceptions are Python's way of signaling problems — they're not failures, they're information.
try/exceptcatches exceptions and lets your program respond instead of crash.- Catch specific exception types (
ValueError,FileNotFoundError, etc.) rather than bareException. as ecaptures the exception object — usestr(e)ortype(e).__name__for details.elseruns only when no exception occurred — for code that depends on success.finallyalways runs — for cleanup that must happen regardless of outcome.raise ExceptionType("message")signals a problem from your own code.- Bare
raiseinside anexceptblock re-raises the current exception. - Create custom exceptions by subclassing
Exception— carry domain-specific information. - Never silently swallow exceptions with
pass— at minimum, log them. - Keep
tryblocks short — only wrap the risky line(s).
What's Next?
You can now write programs that handle failure gracefully. The next chapter takes a different direction — not error handling, but elegance.
In Chapter 14 you'll master comprehensions: list comprehensions, dictionary comprehensions, set comprehensions, and generator expressions. You've seen list comprehensions in Chapter 6 and dictionary comprehensions in Chapter 12. Now you'll learn all the forms, understand when to use them vs. loops, and see how they can make your code dramatically shorter and clearer.
Your turn: Write a function called safe_open_json(filepath) that tries to open and parse a JSON file (import json, then json.load(file)). Handle three specific errors: FileNotFoundError (file doesn't exist), json.JSONDecodeError (file isn't valid JSON), and PermissionError (no read access). Return the parsed data on success or None on failure, printing a specific message for each error type.