Error Handling

Chapter 7: Error Handling

Errors are inevitable in programming. User input might be invalid, files might not exist, network connections might fail. Instead of letting programs crash, Python provides exception handling to gracefully manage errors, provide helpful feedback, and maintain program stability.

For example, asking a user for a number but receiving text would normally crash your program. With error handling, you catch the error, display a friendly message, and ask again. Mastering exception handling transforms fragile scripts into robust applications that handle the unexpected gracefully.

Understanding Exceptions

Exceptions are Python's way of signaling that something went wrong. When an error occurs, Python "raises" an exception—creating an object that describes the problem and stopping normal execution.

Common exception types include:

  • ValueError - Invalid value (e.g., int("hello"))
  • TypeError - Wrong type (e.g., "text" + 5)
  • ZeroDivisionError - Division by zero
  • KeyError - Dictionary key doesn't exist
  • IndexError - List index out of range
  • FileNotFoundError - File doesn't exist

Each exception type provides specific information about what went wrong, helping you diagnose and fix problems.

The try-except Block

The try-except block catches exceptions, allowing your program to continue running:

Python tries to execute the try block. If an exception occurs, it immediately jumps to the matching except block. The program continues after the entire try-except structure, rather than crashing.

Catching Multiple Exceptions

Handle multiple exception types with separate except clauses:

def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    except TypeError:
        print("Both arguments must be numbers")
        return None

Or catch multiple exceptions together:

try:
    value = int(input("Enter a number: "))
    result = 100 / value
except (ValueError, ZeroDivisionError) as error:
    print(f"Error occurred: {error}")

The as error clause captures the exception object, letting you access error details.

The Bare except Clause

A bare except catches all exceptions, but use it sparingly—it hides unexpected errors:

try:
    # Some risky operation
    data = process_data()
except:
    print("Something went wrong")  # Too vague, hard to debug

Better practice: catch specific exceptions you expect, letting unexpected ones surface for debugging.

The else and finally Clauses

The else clause runs only if the try block succeeds without exceptions:

try:
    value = int(input("Enter a number: "))
except ValueError:
    print("Invalid number")
else:
    print(f"You entered: {value}")  # Only runs if no exception

This separates success logic from error handling, making code clearer.

The finally clause always runs, whether an exception occurred or not. It's perfect for cleanup tasks:

resource = None
try:
    resource = acquire_resource()  # Get some resource
    data = process(resource)
    print(f"Processed: {data}")
except ResourceError:
    print("Resource unavailable")
finally:
    if resource:
        release_resource(resource)  # Always runs, ensuring cleanup
    print("Cleanup complete")

The finally block executes even if exceptions occur or return statements execute in try or except.

Raising Exceptions

Use the raise keyword to create exceptions when your code detects problems:

def calculate_discount(price, discount_percent):
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Discount must be between 0 and 100")

    return price * (1 - discount_percent / 100)

try:
    final_price = calculate_discount(100, 150)  # Invalid discount
except ValueError as error:
    print(f"Error: {error}")  # Prints: Error: Discount must be between 0 and 100

Raising exceptions with descriptive messages helps users understand what went wrong and how to fix it.

Re-raising Exceptions

Sometimes you catch an exception, log it, then re-raise it for higher-level handling:

def parse_config(config_string):
    try:
        data = json.loads(config_string)
        return data
    except json.JSONDecodeError as error:
        print(f"Invalid JSON format: {error}")
        raise  # Re-raise the same exception

The bare raise statement re-raises the current exception, preserving its type and message.

Practical Error Handling Patterns

Input Validation:

def get_positive_number():
    while True:
        try:
            value = int(input("Enter a positive number: "))
            if value <= 0:
                print("Number must be positive")
                continue
            return value
        except ValueError:
            print("Please enter a valid integer")

Safe Dictionary Access:

def get_user_setting(settings, key, default=None):
    try:
        return settings[key]
    except KeyError:
        return default

settings = {"theme": "dark", "language": "en"}
font = get_user_setting(settings, "font", "Arial")  # Returns "Arial"

Graceful Degradation:

def load_data(source):
    try:
        # Try primary data source
        return fetch_from_api(source)
    except NetworkError:
        try:
            # Fall back to cached data
            return load_from_cache()
        except CacheError:
            # Last resort: return default data
            return get_default_data()

Best Practices

  1. Catch specific exceptions - Don't use bare except that hides problems
  2. Handle exceptions at the right level - Catch close to where you can fix the issue
  3. Provide helpful error messages - Tell users what went wrong and how to fix it
  4. Use finally for cleanup - Ensure resources (files, connections) are always released
  5. Don't silence exceptions - Log or handle them, don't just pass
  6. Fail fast - Raise exceptions early when detecting invalid state

Exception handling isn't about hiding errors—it's about managing them gracefully, maintaining program stability, and guiding users through problems.

Try It Yourself: Practice Exercises

Exercise 1: Safe Division
Write a function that takes two numbers and returns their division. Handle ZeroDivisionError and TypeError, returning appropriate error messages.

Exercise 2: Input Loop
Create a loop that asks for a number between 1 and 10. Use try-except to handle ValueError, and validate the range without exceptions.

Exercise 3: File Processor
Write a function that reads a file and counts lines. Handle FileNotFoundError gracefully, returning 0 if the file doesn't exist.

Exercise 4: Dictionary Validator
Create a function that validates a user dictionary has required keys ("name", "age", "email"). Raise ValueError with specific messages for missing keys.

Exercise 5: Exception Re-raiser
Write a function that converts a string to an integer. Catch ValueError, log the error, then re-raise the exception.

Exercise 6: Multiple Exception Handler
Create a calculator function that handles both ZeroDivisionError and ValueError, providing different messages for each.

What's Next?

You've mastered Python error handling: understanding exceptions, using try-except blocks, utilizing else and finally clauses, and raising exceptions. These skills let you build robust programs that handle the unexpected gracefully and provide great user experiences.

In the next chapter, we'll explore object-oriented programming—creating classes, objects, inheritance, and encapsulation. OOP lets you model real-world concepts in code, creating reusable, organized, and scalable programs.

Continue to Chapter 8: Object-Oriented Programming

📝 Test Your Knowledge: Error Handling

Take this quiz to reinforce what you've learned in this chapter.