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 try and except blocks to handle errors
  • Catch specific types of exceptions
  • Use else and finally clauses
  • 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 zero

The 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 integer

FileNotFoundError:

with open("nonexistent.csv", "r") as file:
    pass

IndexError:

numbers = [1, 2, 3]
print(numbers[5])  # Index 5 doesn't exist

KeyError:

book = {"title": "Desert Echoes", "price": 15.00}
print(book["rating"])  # Key "rating" doesn't exist

TypeError:

result = "10" + 5  # Can't add string and integer

Now 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:

  1. Python tries to execute the code in the try block
  2. If a ZeroDivisionError occurs, it jumps to the except block
  3. The code in the except block runs
  4. 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 TypeError

Output:

5.0
Error: Cannot divide by zero!
None
Error: Both values must be numbers!
None

You 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 5

You 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:

  1. Handles file access errors (file not found, no permission)
  2. Validates CSV structure and data types
  3. Collects and reports errors without crashing
  4. Provides detailed error messages
  5. 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 finally blocks (or use with)
  • Validate user input
  • Handle expected errors gracefully

DON’T:

  • Use bare except: (catch Exception instead)
  • 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 None

Bad example:

def unsafe_divide(a, b):
    try:
        return a / b
    except:  # Too broad, hides bugs
        pass  # Silent failure, hard to debug

Looking 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:

  1. Reads multiple CSV files specified in a list
  2. Handles missing files gracefully
  3. Validates data in each file
  4. Combines valid data from all files
  5. Reports any errors encountered
  6. Writes the combined, validated data to a new CSV file