Virtual assistance

Python Exception Handling

Learn how to handle errors and exceptions in Python. Master try-except blocks, custom exceptions, and robust error management techniques.

Python Exception Handling

What are Exceptions?

Exceptions are errors that occur during program execution. Python provides mechanisms to handle these exceptions gracefully, preventing program crashes and enabling recovery.

"Exception handling allows programs to continue running despite errors."

Basic Try-Except

Use try-except to handle exceptions:

# Basic exception handling
try:
    result = 10 / 0
    print(result)
except ZeroDivisionError:
    print("Cannot divide by zero!")

# Multiple exception types
try:
    number = int("abc")
except ValueError:
    print("Invalid number format")
except TypeError:
    print("Type conversion error")

# Catch all exceptions (not recommended)
try:
    risky_operation()
except Exception as e:
    print(f"An error occurred: {e}")

# Using else clause
try:
    result = 10 / 2
except ZeroDivisionError:
    print("Division by zero")
else:
    print(f"Result: {result}")  # Executes if no exception

# Finally clause (always executes)
try:
    file = open("example.txt", "r")
    content = file.read()
except FileNotFoundError:
    print("File not found")
finally:
    if 'file' in locals():
        file.close()
    print("Cleanup completed")

Common Built-in Exceptions

Python's built-in exception hierarchy:

ExceptionDescriptionExample
ZeroDivisionErrorDivision by zero10 / 0
ValueErrorInvalid value for operationint("abc")
TypeErrorOperation on wrong type"a" + 1
IndexErrorList index out of rangelist[10]
KeyErrorDictionary key not founddict["missing"]
FileNotFoundErrorFile doesn't existopen("missing.txt")
AttributeErrorInvalid attribute accessobj.missing_method()
ImportErrorModule import failureimport nonexistent
NameErrorUndefined variableprint(undefined_var)

Raising Exceptions

Use raise to throw exceptions:

# Raise built-in exception
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

# Raise with custom message
def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")

# Re-raising exceptions
try:
    risky_function()
except Exception as e:
    print(f"Error occurred: {e}")
    raise  # Re-raises the same exception

# Raising different exception
try:
    result = api_call()
except ConnectionError:
    raise RuntimeError("Service unavailable") from None

Custom Exceptions

Create your own exception classes:

# Basic custom exception
class CustomError(Exception):
    pass

# Custom exception with message
class ValidationError(Exception):
    def __init__(self, message, field=None):
        super().__init__(message)
        self.field = field

# Using custom exceptions
def validate_user(user_data):
    if not user_data.get('name'):
        raise ValidationError("Name is required", "name")
    
    if not user_data.get('email'):
        raise ValidationError("Email is required", "email")
    
    if '@' not in user_data['email']:
        raise ValidationError("Invalid email format", "email")

try:
    validate_user({"name": "John"})
except ValidationError as e:
    print(f"Validation error in {e.field}: {e}")

# Exception hierarchy
class ApplicationError(Exception):
    pass

class DatabaseError(ApplicationError):
    pass

class NetworkError(ApplicationError):
    pass

# Catching hierarchy
try:
    database_operation()
except DatabaseError:
    print("Database issue")
except NetworkError:
    print("Network issue")
except ApplicationError:
    print("General application error")

Context Managers and Exceptions

Use context managers for resource management:

# File handling with context manager
with open('file.txt', 'r') as file:
    content = file.read()
# File automatically closed even if exception occurs

# Custom context manager
class DatabaseConnection:
    def __enter__(self):
        self.connection = connect_to_database()
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.connection.close()
        if exc_type:
            print(f"Exception occurred: {exc_val}")
            return False  # Propagate exception
        return True

with DatabaseConnection() as conn:
    # Use connection
    pass

# Using contextlib
from contextlib import contextmanager

@contextmanager
def database_connection():
    conn = connect_to_database()
    try:
        yield conn
    finally:
        conn.close()

with database_connection() as conn:
    # Use connection
    pass

Exception Chaining

Link related exceptions:

# Exception chaining with 'from'
try:
    process_data()
except ValueError as e:
    raise RuntimeError("Data processing failed") from e

# Suppress chaining with 'from None'
try:
    api_call()
except Exception:
    raise ValueError("Invalid API response") from None

# Accessing chained exceptions
try:
    risky_operation()
except Exception as e:
    print(f"Original exception: {e}")
    if e.__cause__:
        print(f"Caused by: {e.__cause__}")
    if e.__context__:
        print(f"During handling of: {e.__context__}")

Best Practices

  • Be specific: Catch specific exceptions, not Exception
  • Use finally: For cleanup operations
  • Don't suppress exceptions: Let them propagate when appropriate
  • Provide meaningful messages: Help with debugging
  • Use context managers: For resource management
  • Log exceptions: For debugging and monitoring
  • Fail fast: Don't continue with invalid state
  • Test exception handling: Ensure it works correctly

Advanced Exception Patterns

# Retry pattern
import time

def retry_operation(max_attempts=3, delay=1):
    for attempt in range(max_attempts):
        try:
            return risky_operation()
        except Exception as e:
            if attempt == max_attempts - 1:
                raise
            print(f"Attempt {attempt + 1} failed: {e}")
            time.sleep(delay)

# Circuit breaker pattern
class CircuitBreaker:
    def __init__(self, failure_threshold=5):
        self.failure_threshold = failure_threshold
        self.failure_count = 0
        self.state = 'closed'
    
    def call(self, func, *args, **kwargs):
        if self.state == 'open':
            raise RuntimeError("Circuit breaker is open")
        
        try:
            result = func(*args, **kwargs)
            self.failure_count = 0
            return result
        except Exception as e:
            self.failure_count += 1
            if self.failure_count >= self.failure_threshold:
                self.state = 'open'
            raise

# Exception grouping
from contextlib import ExitStack

def process_files(file_list):
    with ExitStack() as stack:
        files = []
        try:
            for filename in file_list:
                file = stack.enter_context(open(filename, 'r'))
                files.append(file)
            # Process all files
            return process_all_files(files)
        except Exception:
            # All files automatically closed
            raise

# Logging exceptions
import logging

logging.basicConfig(level=logging.ERROR)

def log_and_reraise(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            logging.error(f"Error in {func.__name__}: {e}", exc_info=True)
            raise
    return wrapper

@log_and_reraise
def critical_operation():
    # Some operation that might fail
    pass

Exception handling is crucial for building robust Python applications. Proper exception handling prevents crashes, provides meaningful error messages, and enables graceful recovery from errors.