Python Exceptions

Exceptions handle runtime errors in Python programs. Instead of crashing, programs can catch and handle errors gracefully. This tutorial covers exception types, handling, and creation.

What are Exceptions?

Exceptions are events that occur during program execution that disrupt normal flow. They signal errors or unusual conditions.

Common Exceptions

# ZeroDivisionError
# result = 10 / 0

# ValueError
# int("not_a_number")

# FileNotFoundError
# open("nonexistent_file.txt")

# IndexError
# my_list = [1, 2, 3]
# my_list[10]

# KeyError
# my_dict = {"a": 1}
# my_dict["missing_key"]

# TypeError
# "string" + 5

These are built-in exceptions. See built-in exceptions documentation.

Try-Except Blocks

Use try-except to catch and handle exceptions.

Basic Exception Handling

try:
    # Code that might raise an exception
    result = 10 / 0
    print(f"Result: {result}")
except ZeroDivisionError:
    # Handle the specific exception
    print("Cannot divide by zero!")

print("Program continues...")

The try block contains risky code. The except block handles specific exceptions.

Catching Multiple Exceptions

def divide_numbers(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        return "Cannot divide by zero"
    except TypeError:
        return "Both arguments must be numbers"
    except Exception as e:
        return f"Unexpected error: {e}"

print(divide_numbers(10, 2))    # 5.0
print(divide_numbers(10, 0))    # Cannot divide by zero
print(divide_numbers(10, "a"))  # Both arguments must be numbers

Catch specific exceptions first, then general ones. Use Exception as a catch-all.

Exception Information

try:
    numbers = [1, 2, 3]
    print(numbers[5])
except IndexError as e:
    print(f"IndexError occurred: {e}")
    print(f"Exception type: {type(e)}")

The as keyword captures the exception object for inspection.

Else and Finally

else runs if no exception occurs. finally always runs.

Using Else

def read_file(filename):
    try:
        with open(filename, "r") as file:
            content = file.read()
    except FileNotFoundError:
        return f"File '{filename}' not found"
    else:
        # Only runs if no exception
        return f"File read successfully. Content length: {len(content)}"

print(read_file("existing_file.txt"))
print(read_file("nonexistent.txt"))

else is useful for code that should only run on success.

Using Finally

def process_file(filename):
    file = None
    try:
        file = open(filename, "r")
        content = file.read()
        return content
    except FileNotFoundError:
        return "File not found"
    finally:
        # Always runs, even if exception occurs
        if file:
            file.close()
            print("File closed")

result = process_file("example.txt")
print(f"Result: {result}")

finally ensures cleanup code always runs. Useful for resource management.

Raising Exceptions

You can raise exceptions when something goes wrong.

Raising Built-in Exceptions

def validate_age(age):
    if not isinstance(age, (int, float)):
        raise TypeError("Age must be a number")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")
    return age

try:
    validate_age(-5)
except ValueError as e:
    print(f"Validation error: {e}")

try:
    validate_age("thirty")
except TypeError as e:
    print(f"Type error: {e}")

Use raise to throw exceptions. Choose appropriate exception types.

Custom Exceptions

Create your own exception classes for specific errors.

class InsufficientFundsError(Exception):
    """Raised when account has insufficient funds."""
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Insufficient funds. Balance: ${balance}, Required: ${amount}")

class BankAccount:
    def __init__(self, balance):
        self.balance = balance
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)
        self.balance -= amount
        return self.balance

account = BankAccount(100)
try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(e)

Inherit from Exception for custom exceptions. Provide meaningful messages.

Exception Hierarchy

Exceptions form a hierarchy. Catch parent classes to handle multiple types.

# ArithmeticError is parent of ZeroDivisionError, OverflowError, etc.
try:
    # Some arithmetic operation
    pass
except ArithmeticError:
    print("Arithmetic error occurred")

# LookupError is parent of IndexError, KeyError
try:
    my_dict = {}
    my_dict["missing"]
except LookupError:
    print("Lookup error occurred")

# Exception is parent of all built-in exceptions
try:
    # Risky code
    pass
except Exception as e:
    print(f"An error occurred: {e}")

Use parent classes for broad exception handling. See exception hierarchy.

Context Managers and Exceptions

Context managers handle resources and exceptions automatically.

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None
    
    def __enter__(self):
        self.connection = f"Connected to {self.db_name}"
        print("Opening connection")
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Closing connection")
        if exc_type:
            print(f"Exception occurred: {exc_val}")
        return False  # Don't suppress exceptions

with DatabaseConnection("mydb") as conn:
    print(f"Using: {conn}")
    # raise ValueError("Something went wrong")

print("Outside context")

Context managers ensure cleanup even when exceptions occur. See context managers.

Best Practices

Specific Exception Handling

# Good: specific exceptions
try:
    with open("file.txt", "r") as file:
        data = file.read()
except FileNotFoundError:
    print("File not found")
except PermissionError:
    print("No permission to read file")

# Bad: too broad
try:
    # code
    pass
except Exception:  # Catches everything, including KeyboardInterrupt
    pass

Catch specific exceptions. Avoid bare except: clauses.

Exception Chaining

def process_data(data):
    try:
        # Process data
        result = int(data)
        return result
    except ValueError as e:
        # Chain the exception
        raise ValueError(f"Invalid data format: {data}") from e

try:
    process_data("not_a_number")
except ValueError as e:
    print(f"Processing failed: {e}")
    print(f"Original cause: {e.__cause__}")

Use from to chain exceptions and preserve context.

Logging Exceptions

import logging

logging.basicConfig(level=logging.ERROR)

def risky_operation():
    try:
        return 10 / 0
    except ZeroDivisionError as e:
        logging.error(f"Division error: {e}")
        raise  # Re-raise after logging

try:
    risky_operation()
except ZeroDivisionError:
    print("Handled the error")

Log exceptions for debugging while still handling them.

Resource Management

# Good: automatic cleanup
with open("file.txt", "r") as file:
    content = file.read()

# Manual cleanup (error-prone)
file = open("file.txt", "r")
try:
    content = file.read()
finally:
    file.close()

Prefer context managers over manual resource management.

Common Patterns

Retry Pattern

import time

def unreliable_operation(max_retries=3):
    for attempt in range(max_retries):
        try:
            # Simulate unreliable operation
            if attempt < 2:
                raise ConnectionError("Network error")
            return "Success!"
        except ConnectionError as e:
            if attempt == max_retries - 1:
                raise  # Re-raise on last attempt
            print(f"Attempt {attempt + 1} failed: {e}")
            time.sleep(1)  # Wait before retry
    return None

result = unreliable_operation()
print(result)

Retry operations that might fail temporarily.

Validation with Exceptions

class ValidationError(Exception):
    pass

def validate_user(user_data):
    errors = []
    
    if not user_data.get("name"):
        errors.append("Name is required")
    if not user_data.get("email"):
        errors.append("Email is required")
    elif "@" not in user_data["email"]:
        errors.append("Invalid email format")
    
    if errors:
        raise ValidationError("; ".join(errors))
    
    return True

try:
    validate_user({"name": "Alice"})
except ValidationError as e:
    print(f"Validation failed: {e}")

Use custom exceptions for validation errors.

Summary

Exceptions allow graceful error handling:

  1. Use try-except for risky operations
  2. Catch specific exceptions first
  3. Use finally for cleanup
  4. Raise meaningful exceptions
  5. Create custom exceptions when needed
  6. Prefer context managers for resources

External Resources:

Related Tutorials:

Last updated on