Error Handling in Python

Error Handling in Python

Master error handling in Python to write robust, reliable applications that gracefully handle unexpected situations.

Why Error Handling Matters

Proper error handling makes your applications:

  • More Reliable: Continue running despite unexpected inputs
  • User-Friendly: Show helpful error messages instead of crashes
  • Maintainable: Easier to debug and fix issues
  • Professional: Handle edge cases gracefully

Understanding Python Exceptions

What Are Exceptions?

Exceptions are events that disrupt the normal flow of a program’s execution. When an error occurs, Python raises an exception.

Common Exception Types

# Built-in exceptions
print("Common exception types:")

# ValueError - inappropriate value
int("hello")  # ValueError: invalid literal for int()

# TypeError - wrong data type
len(123)  # TypeError: object of type 'int' has no len()

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

# KeyError - missing dictionary key
my_dict = {"name": "Alice"}
print(my_dict["age"])  # KeyError: 'age'

# FileNotFoundError - file doesn't exist
with open("nonexistent.txt") as f:  # FileNotFoundError
    pass

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

Basic Exception Handling

Try-Except Blocks

The fundamental structure for handling exceptions:

try:
    # Code that might raise an exception
    result = 10 / 0
except ZeroDivisionError:
    # Code that runs if the exception occurs
    print("Cannot divide by zero!")

print("Program continues...")

Handling Multiple Exceptions

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 numbers must be numeric")
        return None

# Usage
print(divide_numbers(10, 2))   # 5.0
print(divide_numbers(10, 0))   # Error message, None
print(divide_numbers("10", 2)) # Error message, None

Multiple Exceptions in One Block

try:
    user_input = input("Enter a number: ")
    number = int(user_input)
    result = 100 / number
    print(f"100 / {number} = {result}")
except (ValueError, ZeroDivisionError) as e:
    print(f"Error: {e}")
except KeyboardInterrupt:
    print("\nOperation cancelled by user")
except Exception as e:
    print(f"Unexpected error: {e}")

Advanced Exception Handling

Try-Except-Else-Finally

Complete exception handling structure:

def process_file(filename):
    try:
        file = open(filename, 'r')
        content = file.read()
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found")
    except PermissionError:
        print(f"Error: No permission to read '{filename}'")
    except Exception as e:
        print(f"Unexpected error: {e}")
    else:
        # Runs only if no exception occurred
        print(f"File read successfully, length: {len(content)}")
        return content
    finally:
        # Always runs, whether exception occurred or not
        if 'file' in locals():
            file.close()
            print("File closed")

# Usage
content = process_file("example.txt")

Exception Hierarchy

# Exception hierarchy in Python
# BaseException
# ├── SystemExit
# ├── KeyboardInterrupt
# ├── GeneratorExit
# └── Exception
#     ├── StopIteration
#     ├── ArithmeticError
#     │   └── ZeroDivisionError
#     ├── LookupError
#     │   ├── IndexError
#     │   └── KeyError
#     ├── OSError
#     │   └── FileNotFoundError
#     ├── ValueError
#     └── TypeError

def handle_exception_hierarchy():
    try:
        # This raises ZeroDivisionError
        result = 10 / 0
    except ArithmeticError:
        print("Caught ArithmeticError (parent of ZeroDivisionError)")
    except Exception:
        print("Caught general Exception")
    except:
        print("Caught everything (not recommended)")

Custom Exceptions

Creating Custom Exception Classes

class ValidationError(Exception):
    """Custom exception for validation errors"""
    pass

class InsufficientFundsError(Exception):
    """Custom exception for insufficient funds"""
    
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Insufficient funds: balance ${balance}, attempted withdrawal ${amount}")

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
    
    def withdraw(self, amount):
        if amount <= 0:
            raise ValidationError("Withdrawal amount must be positive")
        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)
        
        self.balance -= amount
        return self.balance

# Usage
account = BankAccount(100)

try:
    account.withdraw(150)
except ValidationError as e:
    print(f"Validation error: {e}")
except InsufficientFundsError as e:
    print(f"Funds error: {e}")

Exception Chaining

class DataProcessor:
    def load_data(self, source):
        try:
            # Simulate data loading error
            with open(source, 'r') as f:
                return f.read()
        except FileNotFoundError as e:
            # Chain with additional context
            raise RuntimeError(f"Failed to load data from {source}") from e

    def process_data(self, data):
        if not data:
            raise ValueError("No data to process")
        return len(data)

# Usage
processor = DataProcessor()
try:
    data = processor.load_data("data.txt")
    result = processor.process_data(data)
except Exception as e:
    print(f"Error: {e}")
    if e.__cause__:
        print(f"Cause: {e.__cause__}")

Best Practices

1. Be Specific in Exception Handling

# Bad - catches everything
try:
    result = dangerous_operation()
except:
    print("Something went wrong")

# Good - catches specific exceptions
try:
    result = dangerous_operation()
except (ValueError, TypeError) as e:
    print(f"Input error: {e}")
except ConnectionError as e:
    print(f"Network error: {e}")

2. Don’t Suppress Exceptions Silently

# Bad - silently ignores errors
try:
    save_to_database(data)
except:
    pass  # Error silently ignored

# Good - log or handle appropriately
import logging

logging.basicConfig(level=logging.ERROR)

try:
    save_to_database(data)
except DatabaseError as e:
    logging.error(f"Database save failed: {e}")
    # Re-raise if you can't handle it
    raise

3. Use Finally for Cleanup

def process_resource():
    resource = None
    try:
        resource = acquire_resource()
        # Work with resource
        result = resource.process()
        return result
    except ResourceError as e:
        print(f"Resource error: {e}")
        raise
    finally:
        # Always cleanup
        if resource:
            resource.release()
            print("Resource released")

4. Provide Context in Error Messages

# Bad - vague error message
def process_user_data(user_id):
    try:
        data = get_user_data(user_id)
        # process data
    except Exception:
        raise Exception("Processing failed")

# Good - detailed error message
def process_user_data(user_id):
    try:
        data = get_user_data(user_id)
        # process data
    except Exception as e:
        raise Exception(f"Failed to process user {user_id}: {str(e)}") from e

Context Managers for Error Handling

Using with Statements

# Custom context manager for database transactions
class DatabaseTransaction:
    def __init__(self, connection):
        self.connection = connection
        self.transaction = None
    
    def __enter__(self):
        self.transaction = self.connection.begin()
        return self.transaction
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # Rollback on exception
            self.transaction.rollback()
            print("Transaction rolled back due to error")
            return False  # Don't suppress exception
        else:
            # Commit on success
            self.transaction.commit()
            print("Transaction committed")
            return True

# Usage
def update_user_record(user_id, new_data):
    conn = get_database_connection()
    
    try:
        with DatabaseTransaction(conn) as tx:
            # All database operations here are atomic
            user = conn.query(User).get(user_id)
            user.update(new_data)
            conn.commit()
            
            # This might cause an exception
            if not user.email:
                raise ValueError("Email is required")
                
    except ValueError as e:
        print(f"Validation error: {e}")
    except Exception as e:
        print(f"Database error: {e}")

Logging and Error Reporting

Structured Error Logging

import logging
import traceback
from datetime import datetime

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

logger = logging.getLogger(__name__)

def handle_api_request(request_data):
    try:
        # Process request
        if not request_data.get('user_id'):
            raise ValueError("Missing user_id in request")
        
        result = process_request(request_data)
        logger.info(f"Request processed successfully: {result}")
        return result
        
    except ValueError as e:
        logger.warning(f"Validation error: {e}")
        return {"error": str(e), "status": 400}
        
    except Exception as e:
        logger.error(f"Unexpected error processing request: {e}")
        logger.error(f"Traceback: {traceback.format_exc()}")
        return {"error": "Internal server error", "status": 500}

# Usage with structured logging
def process_with_logging(operation_name, data):
    start_time = datetime.now()
    
    try:
        result = perform_operation(data)
        
        logger.info(
            "Operation completed",
            extra={
                "operation": operation_name,
                "duration_ms": (datetime.now() - start_time).total_seconds() * 1000,
                "status": "success"
            }
        )
        
        return result
        
    except Exception as e:
        logger.error(
            "Operation failed",
            extra={
                "operation": operation_name,
                "duration_ms": (datetime.now() - start_time).total_seconds() * 1000,
                "status": "error",
                "error": str(e)
            }
        )
        raise

Testing Exception Handling

Writing Tests for Exceptions

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_validation_error():
    with pytest.raises(ValidationError) as exc_info:
        process_user_data({})  # Missing required fields
    
    assert "email" in str(exc_info.value)
    assert exc_info.value.field == "email"

def test_exception_chaining():
    with pytest.raises(RuntimeError) as exc_info:
        processor = DataProcessor()
        processor.load_data("missing.txt")
    
    assert exc_info.value.__cause__ is not None
    assert isinstance(exc_info.value.__cause__, FileNotFoundError)

Real-World Examples

File Processing with Robust Error Handling

import os
import json
from pathlib import Path

class DataFileProcessor:
    def __init__(self, data_directory):
        self.data_directory = Path(data_directory)
        self.logger = logging.getLogger(__name__)
    
    def process_all_files(self):
        """Process all JSON files in directory with robust error handling"""
        results = {"processed": 0, "errors": 0, "details": []}
        
        if not self.data_directory.exists():
            raise FileNotFoundError(f"Directory {self.data_directory} does not exist")
        
        try:
            for file_path in self.data_directory.glob("*.json"):
                try:
                    self._process_single_file(file_path)
                    results["processed"] += 1
                    results["details"].append({"file": file_path.name, "status": "success"})
                    
                except json.JSONDecodeError as e:
                    results["errors"] += 1
                    results["details"].append({
                        "file": file_path.name, 
                        "status": "error", 
                        "error": f"Invalid JSON: {e}"
                    })
                    self.logger.warning(f"Invalid JSON in {file_path}: {e}")
                    
                except Exception as e:
                    results["errors"] += 1
                    results["details"].append({
                        "file": file_path.name, 
                        "status": "error", 
                        "error": str(e)
                    })
                    self.logger.error(f"Error processing {file_path}: {e}")
                    
        except PermissionError:
            raise PermissionError(f"No permission to read directory {self.data_directory}")
        
        return results
    
    def _process_single_file(self, file_path):
        """Process individual file with validation"""
        if not file_path.is_file():
            raise FileNotFoundError(f"File {file_path} is not a regular file")
        
        if file_path.stat().st_size == 0:
            raise ValueError(f"File {file_path} is empty")
        
        with open(file_path, 'r', encoding='utf-8') as f:
            data = json.load(f)
        
        if not isinstance(data, dict):
            raise ValueError(f"File {file_path} must contain a JSON object")
        
        if 'id' not in data:
            raise ValueError(f"File {file_path} missing required 'id' field")
        
        # Process the data...
        self.logger.info(f"Successfully processed {file_path}")

Error Handling Checklist

Code Review

  • Are exceptions caught specifically?
  • Are resources cleaned up in finally blocks?
  • Are error messages informative?
  • Are exceptions logged appropriately?
  • Are custom exceptions used for domain-specific errors?

Runtime

  • Does the program handle unexpected input gracefully?
  • Are users shown helpful error messages?
  • Are errors logged for debugging?
  • Does the application continue running after non-critical errors?

Testing

  • Are exception paths tested?
  • Are edge cases covered?
  • Are error messages validated?
  • Is error recovery behavior tested?

Resources

Next Steps

Now that you understand error handling, explore testing your Python applications to ensure your error handling works correctly.

Last updated on