Skip to main content Brad's PyNotes

Errors and Exceptions Tutorial: Robust Error Handling in Python

TL;DR

Python handles errors through exceptions using try/except/finally blocks, with built-in exception types like ValueError, TypeError, and FileNotFoundError, plus the ability to create custom exceptions and proper error handling patterns.

Interesting!

Python’s exception handling follows the “Easier to Ask for Forgiveness than Permission” (EAFP) principle - it’s often better to try an operation and handle the exception rather than checking conditions beforehand, leading to cleaner and more efficient code.

Types of Errors in Python

python code snippet start

# Syntax Errors - detected before execution
# print("Hello World"  # Missing closing parenthesis

# Runtime Errors - occur during execution
# 1. NameError - undefined variable
# print(undefined_variable)

# 2. TypeError - wrong type operation
# result = "hello" + 5

# 3. ValueError - correct type, wrong value
# number = int("not_a_number")

# 4. IndexError - list index out of range
# my_list = [1, 2, 3]
# print(my_list[10])

# 5. KeyError - dictionary key doesn't exist
# my_dict = {"a": 1, "b": 2}
# print(my_dict["c"])

# 6. FileNotFoundError - file doesn't exist
# with open("nonexistent.txt", "r") as f:
#     content = f.read()

# 7. ZeroDivisionError - division by zero
# result = 10 / 0

python code snippet end

Basic Exception Handling

python code snippet start

# Basic try-except block
try:
    number = int(input("Enter a number: "))
    result = 10 / number
    print(f"Result: {result}")
except ValueError:
    print("Invalid input! Please enter a valid number.")
except ZeroDivisionError:
    print("Cannot divide by zero!")

# Multiple exceptions in one except block
try:
    number = int(input("Enter a number: "))
    result = 10 / number
    print(f"Result: {result}")
except (ValueError, ZeroDivisionError) as e:
    print(f"Error occurred: {e}")

# Generic exception handling
try:
    # Some risky operation
    risky_operation()
except Exception as e:
    print(f"An error occurred: {e}")

# Catching specific exception and storing error info
try:
    with open("data.txt", "r") as file:
        content = file.read()
except FileNotFoundError as e:
    print(f"File not found: {e}")
    print(f"Error type: {type(e).__name__}")

python code snippet end

The else and finally Clauses

python code snippet start

# else clause - executes if no exception occurs
try:
    number = int(input("Enter a number: "))
    result = 100 / number
except ValueError:
    print("Invalid input!")
except ZeroDivisionError:
    print("Cannot divide by zero!")
else:
    print(f"Success! Result is {result}")
    # This only runs if no exception occurred

# finally clause - always executes
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
    except PermissionError:
        print(f"Permission denied for {filename}")
        return None
    finally:
        # This always runs, even if exception occurs
        if file:
            file.close()
            print("File closed")

# Complete try-except-else-finally example
def divide_numbers(a, b):
    try:
        result = a / b
    except TypeError:
        print("Both arguments must be numbers")
        return None
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    else:
        print("Division successful")
        return result
    finally:
        print("Division operation completed")

print(divide_numbers(10, 2))   # Success case
print(divide_numbers(10, 0))   # Zero division
print(divide_numbers(10, "2")) # Type error

python code snippet end

Raising Exceptions

python code snippet start

# Raising built-in exceptions
def validate_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")
    return True

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

# Re-raising exceptions
def process_data(data):
    try:
        # Some processing that might fail
        result = risky_function(data)
        return result
    except ValueError as e:
        print(f"Logging error: {e}")
        raise  # Re-raise the same exception

# Raising exceptions with custom messages
def calculate_square_root(number):
    if number < 0:
        raise ValueError(f"Cannot calculate square root of negative number: {number}")
    return number ** 0.5

# Chaining exceptions (Python 3+)
def outer_function():
    try:
        inner_function()
    except ValueError as e:
        raise RuntimeError("Outer function failed") from e

def inner_function():
    raise ValueError("Inner function error")

try:
    outer_function()
except RuntimeError as e:
    print(f"Main error: {e}")
    print(f"Caused by: {e.__cause__}")

python code snippet end

Custom Exceptions

python code snippet start

# Basic custom exception
class CustomError(Exception):
    """A custom exception class"""
    pass

# Custom exception with additional attributes
class ValidationError(Exception):
    def __init__(self, message, field_name, value):
        super().__init__(message)
        self.field_name = field_name
        self.value = value
    
    def __str__(self):
        return f"Validation failed for {self.field_name}: {self.args[0]} (value: {self.value})"

# Exception hierarchy for a web application
class WebAppError(Exception):
    """Base exception for web application"""
    pass

class AuthenticationError(WebAppError):
    """User authentication failed"""
    pass

class AuthorizationError(WebAppError):
    """User not authorized for this action"""
    pass

class DatabaseError(WebAppError):
    """Database operation failed"""
    pass

class APIError(WebAppError):
    """External API call failed"""
    def __init__(self, message, status_code=None, response=None):
        super().__init__(message)
        self.status_code = status_code
        self.response = response

# Using custom exceptions
def authenticate_user(username, password):
    if not username:
        raise AuthenticationError("Username is required")
    if not password:
        raise AuthenticationError("Password is required")
    
    # Simulate authentication
    if username != "admin" or password != "secret":
        raise AuthenticationError("Invalid credentials")
    
    return True

def authorize_action(user, action):
    if user != "admin":
        raise AuthorizationError(f"User {user} not authorized for {action}")

# Exception handling with custom exceptions
try:
    authenticate_user("user", "wrong")
except AuthenticationError as e:
    print(f"Auth failed: {e}")
except WebAppError as e:
    print(f"Web app error: {e}")

python code snippet end

Real-World Exception Handling Patterns

File Operations

python code snippet start

def safe_file_read(filename, encoding='utf-8'):
    """Safely read a file with comprehensive error handling"""
    try:
        with open(filename, 'r', encoding=encoding) as file:
            return file.read()
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found")
        return None
    except PermissionError:
        print(f"Error: Permission denied for '{filename}'")
        return None
    except UnicodeDecodeError as e:
        print(f"Error: Cannot decode file '{filename}' with {encoding} encoding")
        print(f"Try a different encoding. Error: {e}")
        return None
    except OSError as e:
        print(f"Error: OS error reading '{filename}': {e}")
        return None
    except Exception as e:
        print(f"Unexpected error reading '{filename}': {e}")
        return None

def safe_file_write(filename, content, encoding='utf-8'):
    """Safely write to a file with error handling"""
    try:
        with open(filename, 'w', encoding=encoding) as file:
            file.write(content)
        return True
    except PermissionError:
        print(f"Error: Permission denied writing to '{filename}'")
        return False
    except OSError as e:
        print(f"Error: Cannot write to '{filename}': {e}")
        return False
    except Exception as e:
        print(f"Unexpected error writing to '{filename}': {e}")
        return False

# Usage
content = safe_file_read("data.txt")
if content:
    processed_content = content.upper()
    if safe_file_write("output.txt", processed_content):
        print("File processed successfully")

python code snippet end

Network Operations

python code snippet start

import json

def fetch_api_data(url, timeout=10):
    """Fetch data from API with error handling"""
    try:
        import requests
        response = requests.get(url, timeout=timeout)
        response.raise_for_status()  # Raises HTTPError for bad status codes
        return response.json()
    
    except requests.exceptions.ConnectionError:
        raise APIError("Failed to connect to the server")
    except requests.exceptions.Timeout:
        raise APIError(f"Request timed out after {timeout} seconds")
    except requests.exceptions.HTTPError as e:
        status_code = e.response.status_code
        raise APIError(f"HTTP error {status_code}", status_code, e.response)
    except json.JSONDecodeError:
        raise APIError("Invalid JSON response from server")
    except Exception as e:
        raise APIError(f"Unexpected error: {e}")

def handle_api_request(url):
    """Handle API request with proper error handling"""
    try:
        data = fetch_api_data(url)
        return {"success": True, "data": data}
    
    except APIError as e:
        error_msg = f"API Error: {e}"
        if hasattr(e, 'status_code') and e.status_code:
            error_msg += f" (Status: {e.status_code})"
        
        return {"success": False, "error": error_msg}
    
    except Exception as e:
        return {"success": False, "error": f"Unexpected error: {e}"}

# Usage
# result = handle_api_request("https://api.example.com/data")
# if result["success"]:
#     print(f"Data received: {result['data']}")
# else:
#     print(f"Failed to fetch data: {result['error']}")

python code snippet end

Database Operations

python code snippet start

class DatabaseManager:
    def __init__(self, connection_string):
        self.connection_string = connection_string
        self.connection = None
    
    def connect(self):
        """Connect to database with error handling"""
        try:
            # Simulate database connection
            # self.connection = database.connect(self.connection_string)
            print("Connected to database")
            return True
        except Exception as e:
            raise DatabaseError(f"Failed to connect to database: {e}")
    
    def execute_query(self, query, parameters=None):
        """Execute database query with error handling"""
        if not self.connection:
            raise DatabaseError("Not connected to database")
        
        try:
            # Simulate query execution
            # cursor = self.connection.cursor()
            # cursor.execute(query, parameters or [])
            # return cursor.fetchall()
            print(f"Executing query: {query}")
            return [{"id": 1, "name": "test"}]  # Mock result
        
        except Exception as e:
            # Log the error with context
            error_msg = f"Query failed: {query[:100]}..."
            if parameters:
                error_msg += f" Parameters: {parameters}"
            raise DatabaseError(f"{error_msg}. Error: {e}")
    
    def close(self):
        """Close database connection"""
        try:
            if self.connection:
                # self.connection.close()
                print("Database connection closed")
        except Exception as e:
            print(f"Error closing database connection: {e}")

# Usage with context manager
class DatabaseContextManager:
    def __init__(self, connection_string):
        self.db_manager = DatabaseManager(connection_string)
    
    def __enter__(self):
        self.db_manager.connect()
        return self.db_manager
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.db_manager.close()
        # Handle any exceptions that occurred in the with block
        if exc_type is not None:
            print(f"Exception in database context: {exc_val}")
        return False  # Don't suppress exceptions

# Usage
try:
    with DatabaseContextManager("connection_string") as db:
        results = db.execute_query("SELECT * FROM users WHERE active = ?", [True])
        print(f"Found {len(results)} users")
except DatabaseError as e:
    print(f"Database operation failed: {e}")

python code snippet end

Debugging and Logging Errors

python code snippet start

import logging
import traceback
from datetime import datetime

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('app.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

def log_exception(func):
    """Decorator to log exceptions"""
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            logger.error(f"Exception in {func.__name__}: {e}")
            logger.error(f"Traceback: {traceback.format_exc()}")
            raise
    return wrapper

@log_exception
def risky_function(data):
    """Function that might raise exceptions"""
    if not data:
        raise ValueError("Data cannot be empty")
    
    # Simulate processing
    return data.upper()

# Exception context for debugging
def detailed_error_handler():
    """Demonstrate detailed error information"""
    try:
        risky_function("")
    except Exception as e:
        # Get detailed exception information
        exc_type, exc_value, exc_traceback = sys.exc_info()
        
        print(f"Exception Type: {exc_type.__name__}")
        print(f"Exception Value: {exc_value}")
        print(f"Exception Args: {exc_value.args}")
        
        # Print traceback
        print("\nTraceback:")
        traceback.print_exc()
        
        # Get formatted traceback as string
        tb_str = traceback.format_exc()
        logger.error(f"Detailed error info:\n{tb_str}")

# Error aggregation for monitoring
class ErrorTracker:
    def __init__(self):
        self.errors = []
    
    def log_error(self, error, context=None):
        """Log error with context for monitoring"""
        error_info = {
            'timestamp': datetime.now().isoformat(),
            'type': type(error).__name__,
            'message': str(error),
            'context': context or {},
            'traceback': traceback.format_exc()
        }
        self.errors.append(error_info)
        logger.error(f"Error logged: {error_info}")
    
    def get_error_summary(self):
        """Get summary of errors for monitoring"""
        if not self.errors:
            return "No errors recorded"
        
        error_counts = {}
        for error in self.errors:
            error_type = error['type']
            error_counts[error_type] = error_counts.get(error_type, 0) + 1
        
        return {
            'total_errors': len(self.errors),
            'error_types': error_counts,
            'latest_error': self.errors[-1] if self.errors else None
        }

# Global error tracker
error_tracker = ErrorTracker()

def process_user_data(user_data):
    """Process user data with error tracking"""
    try:
        # Validate user data
        if 'email' not in user_data:
            raise ValidationError("Email is required", "email", None)
        
        if '@' not in user_data['email']:
            raise ValidationError("Invalid email format", "email", user_data['email'])
        
        # Process the data
        return {"status": "success", "user_id": 123}
        
    except ValidationError as e:
        error_tracker.log_error(e, {"user_data": user_data})
        return {"status": "error", "message": str(e)}
    
    except Exception as e:
        error_tracker.log_error(e, {"user_data": user_data})
        return {"status": "error", "message": "Internal server error"}

# Usage
# result = process_user_data({"name": "John"})  # Missing email
# print(error_tracker.get_error_summary())

python code snippet end

Best Practices for Exception Handling

python code snippet start

# 1. Be specific with exceptions
# Bad - too generic
try:
    value = int(user_input)
except:  # Don't catch all exceptions
    print("Error occurred")

# Good - specific exceptions
try:
    value = int(user_input)
except ValueError:
    print("Invalid number format")
except TypeError:
    print("Input must be a string")

# 2. Don't ignore exceptions silently
# Bad
try:
    risky_operation()
except Exception:
    pass  # Silent failure is dangerous

# Good
try:
    risky_operation()
except Exception as e:
    logger.error(f"Operation failed: {e}")
    # Handle appropriately

# 3. Use specific exception types
def validate_positive_number(value):
    if not isinstance(value, (int, float)):
        raise TypeError(f"Expected number, got {type(value).__name__}")
    if value <= 0:
        raise ValueError(f"Expected positive number, got {value}")
    return True

# 4. Provide helpful error messages
class ConfigurationError(Exception):
    def __init__(self, setting_name, value, reason):
        message = f"Invalid configuration for '{setting_name}': {reason} (value: {value})"
        super().__init__(message)
        self.setting_name = setting_name
        self.value = value
        self.reason = reason

# 5. Use context managers for resource management
def read_config_file(filename):
    """Properly handle file resources"""
    try:
        with open(filename, 'r') as file:  # Automatic cleanup
            return json.load(file)
    except FileNotFoundError:
        raise ConfigurationError("config_file", filename, "file not found")
    except json.JSONDecodeError as e:
        raise ConfigurationError("config_file", filename, f"invalid JSON: {e}")

# 6. Chain exceptions for context
def load_user_settings(user_id):
    try:
        config = read_config_file(f"user_{user_id}.json")
        return config
    except ConfigurationError as e:
        raise RuntimeError(f"Failed to load settings for user {user_id}") from e

# 7. Validate inputs early
def calculate_interest(principal, rate, time):
    # Validate inputs first
    if not all(isinstance(x, (int, float)) for x in [principal, rate, time]):
        raise TypeError("All parameters must be numbers")
    
    if principal <= 0:
        raise ValueError("Principal must be positive")
    if rate < 0:
        raise ValueError("Rate cannot be negative")
    if time <= 0:
        raise ValueError("Time must be positive")
    
    return principal * (1 + rate) ** time

python code snippet end

Testing Exception Handling

python code snippet start

import unittest

class TestExceptionHandling(unittest.TestCase):
    
    def test_value_error_raised(self):
        """Test that ValueError is raised for invalid input"""
        with self.assertRaises(ValueError):
            calculate_square_root(-1)
    
    def test_exception_message(self):
        """Test that exception has correct message"""
        with self.assertRaises(ValueError) as context:
            validate_age(-5)
        
        self.assertIn("negative", str(context.exception))
    
    def test_custom_exception_attributes(self):
        """Test custom exception attributes"""
        with self.assertRaises(ValidationError) as context:
            raise ValidationError("Test error", "test_field", "test_value")
        
        error = context.exception
        self.assertEqual(error.field_name, "test_field")
        self.assertEqual(error.value, "test_value")
    
    def test_exception_handling_behavior(self):
        """Test that function handles exceptions correctly"""
        result = process_user_data({})  # Invalid data
        self.assertEqual(result["status"], "error")
        self.assertIn("Email is required", result["message"])

# Run tests
if __name__ == "__main__":
    unittest.main()

python code snippet end

Proper exception handling makes your Python programs robust, maintainable, and user-friendly by gracefully managing errors and providing meaningful feedback.

Combine robust exception handling with Python's logging module to create production-ready applications that can diagnose and recover from problems effectively. These patterns integrate with control flow structures and support Zen of Python principle that “errors should never pass silently.”

Reference: Python Tutorial - Errors and Exceptions