Lesson 11 - Exception Handling
Building Robust Programs
When working with real-world data, things don’t always go as expected. Files might be missing, data might be in the wrong format, or users might enter invalid input. Without proper error handling, your program will crash when it encounters these problems.
Exception handling allows you to write code that anticipates and gracefully handles errors, making your programs more reliable and user-friendly.
By the end of this lesson, you’ll be able to:
- Understand what exceptions are and why they occur
- Use
tryandexceptblocks to handle errors - Catch specific types of exceptions
- Use
elseandfinallyclauses - Raise your own exceptions
- Apply exception handling to data processing tasks
Let’s start by understanding what exceptions are.
Understanding Exceptions
An exception is an error that occurs during program execution. When Python encounters an error, it “raises” an exception and stops the program.
Here’s a simple example:
result = 10 / 0
print(result)Output:
Traceback (most recent call last):
File "example.py", line 1, in <module>
result = 10 / 0
ZeroDivisionError: division by zeroThe program crashed with a ZeroDivisionError. The error message tells us:
- What type of exception occurred (
ZeroDivisionError) - Where it happened (line 1)
- What caused it (division by zero)
Here are some common exceptions you’ll encounter:
ValueError:
number = int("abc") # Can't convert "abc" to an integerFileNotFoundError:
with open("nonexistent.csv", "r") as file:
passIndexError:
numbers = [1, 2, 3]
print(numbers[5]) # Index 5 doesn't existKeyError:
book = {"title": "Desert Echoes", "price": 15.00}
print(book["rating"]) # Key "rating" doesn't existTypeError:
result = "10" + 5 # Can't add string and integerNow let’s learn how to handle these exceptions.
The try-except Block
The try-except block allows you to “try” code that might cause an error and specify what to do if an error occurs:
try:
result = 10 / 0
print(result)
except ZeroDivisionError:
print("Error: Cannot divide by zero!")Output:
Error: Cannot divide by zero!The program no longer crashes! Instead:
- Python tries to execute the code in the
tryblock - If a
ZeroDivisionErroroccurs, it jumps to theexceptblock - The code in the
exceptblock runs - The program continues normally
Let’s apply this to a practical example. Imagine reading ratings from user input:
def get_rating():
try:
rating = float(input("Enter a rating (0-5): "))
return rating
except ValueError:
print("Invalid input! Please enter a number.")
return None
user_rating = get_rating()
if user_rating is not None:
print(f"You entered: {user_rating}")If the user enters “abc”, the program doesn’t crash—it prints an error message and returns None.
Exercise: Write code that asks the user for two numbers and divides them. Use try-except to handle the case where the user enters non-numeric input or tries to divide by zero.
Catching Multiple Exceptions
You can handle different exception types with separate except blocks:
def divide_numbers(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
except TypeError:
print("Error: Both values must be numbers!")
return None
print(divide_numbers(10, 2)) # Works fine
print(divide_numbers(10, 0)) # Catches ZeroDivisionError
print(divide_numbers(10, "a")) # Catches TypeErrorOutput:
5.0
Error: Cannot divide by zero!
None
Error: Both values must be numbers!
NoneYou can also catch multiple exception types in one block:
try:
# Some code
pass
except (ValueError, TypeError) as error:
print(f"Invalid input: {error}")Or catch all exceptions with a bare except:
try:
# Some code
pass
except Exception as error:
print(f"An error occurred: {error}")Important: Catching all exceptions should be used sparingly. It’s usually better to catch specific exception types so you know exactly what went wrong.
The else Clause
The else clause runs only if no exception occurred in the try block:
def read_rating_from_file(filename):
try:
with open(filename, 'r') as file:
rating = float(file.read())
except FileNotFoundError:
print(f"Error: {filename} not found!")
return None
except ValueError:
print(f"Error: {filename} doesn't contain a valid number!")
return None
else:
print(f"Successfully read rating from {filename}")
return rating
rating = read_rating_from_file("rating.txt")
if rating is not None:
print(f"Rating: {rating}")This makes the code more readable by separating:
- Code that might raise exceptions (
try) - Error handling (
except) - Code that should only run if there were no errors (
else)
The finally Clause
The finally clause always runs, whether an exception occurred or not. This is useful for cleanup tasks like closing files or database connections:
def process_file(filename):
file = None
try:
file = open(filename, 'r')
data = file.read()
print(f"Read {len(data)} characters")
return data
except FileNotFoundError:
print(f"Error: {filename} not found!")
return None
finally:
if file:
file.close()
print("File closed")
process_file("books.txt")Even if an exception occurs, the finally block ensures the file gets closed.
Note: When using the with statement for files, you don’t need finally because with automatically handles cleanup.
Here’s a practical example:
def calculate_average(numbers_file):
try:
with open(numbers_file, 'r') as file:
numbers = []
for line in file:
try:
number = float(line.strip())
numbers.append(number)
except ValueError:
print(f"Skipping invalid line: {line.strip()}")
if numbers:
average = sum(numbers) / len(numbers)
return average
else:
print("No valid numbers found")
return None
except FileNotFoundError:
print(f"Error: {numbers_file} not found!")
return None
finally:
print("Processing complete")
avg = calculate_average("ratings.txt")
if avg is not None:
print(f"Average rating: {avg:.2f}")Exercise: Write a function that reads a CSV file and calculates the average of a specific column. Use try-except to handle file not found errors and invalid data in the CSV.
Raising Exceptions
Sometimes you want to raise your own exceptions when certain conditions aren’t met:
def set_rating(rating):
if not isinstance(rating, (int, float)):
raise TypeError("Rating must be a number")
if rating < 0 or rating > 5:
raise ValueError("Rating must be between 0 and 5")
return rating
try:
set_rating(6)
except ValueError as error:
print(f"Invalid rating: {error}")Output:
Invalid rating: Rating must be between 0 and 5You can also create custom exception classes:
class InvalidRatingError(Exception):
"""Exception raised for invalid rating values"""
pass
def validate_rating(rating):
if rating < 0 or rating > 5:
raise InvalidRatingError(f"Rating {rating} is out of range (0-5)")
return rating
try:
validate_rating(7)
except InvalidRatingError as error:
print(f"Error: {error}")Output:
Error: Rating 7 is out of range (0-5)Practical Example: Robust Data Processing
Let’s create a complete program that reads book data with comprehensive error handling:
import csv
def load_books(filename):
"""Load book data from CSV with error handling"""
books = []
errors = []
try:
with open(filename, 'r') as file:
reader = csv.reader(file)
# Skip header
try:
header = next(reader)
except StopIteration:
raise ValueError("CSV file is empty")
# Process each row
for line_num, row in enumerate(reader, start=2):
try:
if len(row) != 3:
raise ValueError(f"Expected 3 columns, got {len(row)}")
title = row[0].strip()
if not title:
raise ValueError("Title cannot be empty")
try:
price = float(row[1])
if price < 0:
raise ValueError("Price cannot be negative")
except ValueError as e:
raise ValueError(f"Invalid price: {e}")
try:
rating = float(row[2])
if not 0 <= rating <= 5:
raise ValueError("Rating must be between 0 and 5")
except ValueError as e:
raise ValueError(f"Invalid rating: {e}")
books.append({
'title': title,
'price': price,
'rating': rating
})
except ValueError as error:
errors.append(f"Line {line_num}: {error}")
continue
return books, errors
except FileNotFoundError:
print(f"Error: File '{filename}' not found")
return None, None
except PermissionError:
print(f"Error: No permission to read '{filename}'")
return None, None
except Exception as error:
print(f"Unexpected error: {error}")
return None, None
def analyze_books(books):
"""Analyze book data with error handling"""
if not books:
print("No books to analyze")
return
try:
total_books = len(books)
avg_price = sum(book['price'] for book in books) / total_books
avg_rating = sum(book['rating'] for book in books) / total_books
free_books = [book for book in books if book['price'] == 0]
paid_books = [book for book in books if book['price'] > 0]
print(f"\n{'='*50}")
print(f"Book Analysis Results")
print(f"{'='*50}")
print(f"Total books: {total_books}")
print(f"Average price: ${avg_price:.2f}")
print(f"Average rating: {avg_rating:.2f}")
print(f"Free books: {len(free_books)} ({len(free_books)/total_books*100:.1f}%)")
print(f"Paid books: {len(paid_books)} ({len(paid_books)/total_books*100:.1f}%)")
if paid_books:
avg_paid_price = sum(book['price'] for book in paid_books) / len(paid_books)
print(f"Average price (paid books): ${avg_paid_price:.2f}")
except ZeroDivisionError:
print("Error: Cannot calculate averages with no books")
except KeyError as error:
print(f"Error: Missing required field: {error}")
except Exception as error:
print(f"Error during analysis: {error}")
# Main program
def main():
filename = "books.csv"
print(f"Loading data from {filename}...")
books, errors = load_books(filename)
if books is None:
print("Failed to load data. Exiting.")
return
if errors:
print(f"\nWarning: Skipped {len(errors)} invalid rows:")
for error in errors[:5]: # Show first 5 errors
print(f" - {error}")
if len(errors) > 5:
print(f" ... and {len(errors) - 5} more")
print(f"\nSuccessfully loaded {len(books)} books")
analyze_books(books)
if __name__ == "__main__":
main()This program:
- Handles file access errors (file not found, no permission)
- Validates CSV structure and data types
- Collects and reports errors without crashing
- Provides detailed error messages
- Continues processing valid data even when some rows are invalid
Best Practices for Exception Handling
DO:
- Catch specific exceptions when possible
- Provide helpful error messages
- Log errors for debugging
- Clean up resources in
finallyblocks (or usewith) - Validate user input
- Handle expected errors gracefully
DON’T:
- Use bare
except:(catchExceptioninstead) - Silently ignore errors without logging them
- Catch exceptions you can’t handle
- Use exceptions for control flow
- Leave resources open after errors
Good example:
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
print("Warning: Division by zero, returning 0")
return 0
except TypeError:
print("Error: Both arguments must be numbers")
return NoneBad example:
def unsafe_divide(a, b):
try:
return a / b
except: # Too broad, hides bugs
pass # Silent failure, hard to debugLooking Ahead
You’ve now learned how to write robust code that handles errors gracefully. Exception handling is essential for building reliable data processing applications that can handle unexpected situations without crashing.
In the next lesson, you’ll learn about list comprehensions—a powerful Python feature that lets you create and transform lists in a single, elegant line of code. This will make your data processing code more concise and often more readable.
Exercise: Create a program that:
- Reads multiple CSV files specified in a list
- Handles missing files gracefully
- Validates data in each file
- Combines valid data from all files
- Reports any errors encountered
- Writes the combined, validated data to a new CSV file