Error Handling

Exceptions, try/except, and error management.

Exceptions Basics

try / except

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")

Multiple Exceptions

try:
    value = int(input("Enter a number: "))
    result = 10 / value
except ValueError:
    print("Invalid number")
except ZeroDivisionError:
    print("Cannot divide by zero")

# Or handle multiple together
except (ValueError, ZeroDivisionError) as e:
    print(f"Error: {e}")

Catch All (Use Sparingly)

try:
    risky_operation()
except Exception as e:
    print(f"Something went wrong: {e}")
    # Log the error
    import traceback
    traceback.print_exc()

else and finally

try:
    result = operation()
except SomeError:
    print("Error occurred")
else:
    # Runs only if no exception
    print(f"Success: {result}")
finally:
    # Always runs (cleanup)
    cleanup()

Getting Exception Info

try:
    risky_operation()
except ValueError as e:
    print(f"Error message: {e}")
    print(f"Error type: {type(e).__name__}")
    print(f"Error args: {e.args}")

    # Full traceback
    import traceback
    traceback.print_exc()

Common Built-in Exceptions

ExceptionWhen Raised
ValueErrorWrong value (e.g., int("abc"))
TypeErrorWrong type (e.g., "2" + 2)
KeyErrorDict key not found
IndexErrorList index out of range
AttributeErrorAttribute not found
NameErrorVariable not defined
FileNotFoundErrorFile doesn't exist
IOErrorI/O operation failed
ZeroDivisionErrorDivision by zero
ImportErrorImport failed
StopIterationIterator exhausted
RuntimeErrorGeneral runtime error
NotImplementedErrorMethod not implemented

Exception Hierarchy

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── StopIteration
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   └── OverflowError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    └── ValueError

Note: Catch Exception, not BaseException, to avoid catching KeyboardInterrupt, etc.

Raising Exceptions

Basic Raise

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Re-raising

try:
    operation()
except SomeError:
    log_error()
    raise  # Re-raise the same exception

Chaining Exceptions

try:
    operation()
except ValueError as e:
    raise RuntimeError("Operation failed") from e

Custom Exceptions

Basic Custom Exception

class ValidationError(Exception):
    """Raised when validation fails."""
    pass

def validate_age(age):
    if age < 0:
        raise ValidationError("Age cannot be negative")
    if age > 150:
        raise ValidationError("Age seems unrealistic")

With Additional Info

class APIError(Exception):
    """Raised when API call fails."""

    def __init__(self, message, status_code=None, response=None):
        super().__init__(message)
        self.status_code = status_code
        self.response = response

    def __str__(self):
        if self.status_code:
            return f"[{self.status_code}] {self.args[0]}"
        return self.args[0]


# Usage
try:
    raise APIError("Not found", status_code=404)
except APIError as e:
    print(e.status_code)  # 404

Exception Hierarchy

class AppError(Exception):
    """Base exception for application."""
    pass

class ValidationError(AppError):
    """Validation failed."""
    pass

class DatabaseError(AppError):
    """Database operation failed."""
    pass

class ConnectionError(DatabaseError):
    """Database connection failed."""
    pass

# Catch all app errors
try:
    operation()
except AppError as e:
    handle_app_error(e)

Context Managers for Cleanup

# Using contextlib
from contextlib import contextmanager

@contextmanager
def managed_resource():
    resource = acquire_resource()
    try:
        yield resource
    finally:
        release_resource(resource)

with managed_resource() as r:
    use(r)

# Suppress specific exceptions
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("might_not_exist.txt")

Assertions

For debugging and development:

def calculate_average(numbers):
    assert len(numbers) > 0, "List cannot be empty"
    return sum(numbers) / len(numbers)

# Assertions can be disabled with -O flag
# Don't use for input validation in production!

Logging Errors

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

try:
    risky_operation()
except Exception as e:
    logger.error(f"Operation failed: {e}")
    logger.exception("Full traceback:")  # Includes traceback

Common Patterns

EAFP vs LBYL

# LBYL: Look Before You Leap
if key in dictionary:
    value = dictionary[key]
else:
    value = default

# EAFP: Easier to Ask Forgiveness than Permission (Pythonic)
try:
    value = dictionary[key]
except KeyError:
    value = default

# Or just use .get()
value = dictionary.get(key, default)

Guard Clauses

def process(data):
    if data is None:
        raise ValueError("Data cannot be None")
    if not data:
        raise ValueError("Data cannot be empty")

    # Main logic here

Try/Except in Loops

# Process all items, log failures
results = []
errors = []

for item in items:
    try:
        results.append(process(item))
    except ProcessingError as e:
        errors.append((item, e))
        continue  # Process next item

if errors:
    logger.warning(f"{len(errors)} items failed")

Retry Pattern

import time

def retry(func, max_attempts=3, delay=1):
    """Retry a function with exponential backoff."""
    for attempt in range(max_attempts):
        try:
            return func()
        except Exception as e:
            if attempt == max_attempts - 1:
                raise
            time.sleep(delay * (2 ** attempt))

Cleanup with try/finally

resource = None
try:
    resource = acquire_resource()
    use_resource(resource)
finally:
    if resource is not None:
        release_resource(resource)

Error Messages

Good Error Messages

# Bad
raise ValueError("Invalid input")

# Good
raise ValueError(f"Expected positive number, got {value}")

# Better
raise ValueError(
    f"Invalid age: {age}. "
    f"Age must be between 0 and 150."
)

Include Context

def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in {path}: {e}")

Testing Exceptions

import pytest

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

def test_validation_error_message():
    with pytest.raises(ValueError, match="cannot be negative"):
        validate_age(-1)

# Using unittest
import unittest

class TestDivide(unittest.TestCase):
    def test_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            divide(1, 0)

Practice

# 1. Safe dictionary access
def safe_get(d, *keys, default=None):
    """Safely get nested dictionary value."""
    try:
        for key in keys:
            d = d[key]
        return d
    except (KeyError, TypeError):
        return default

data = {"user": {"name": "Alice"}}
safe_get(data, "user", "name")  # "Alice"
safe_get(data, "user", "age")   # None

# 2. Validate and convert
def parse_int(value, name="value"):
    """Parse integer with helpful error message."""
    try:
        return int(value)
    except (ValueError, TypeError):
        raise ValueError(f"{name} must be an integer, got: {value!r}")

# 3. Batch processing with error collection
def process_batch(items):
    results = []
    errors = []

    for i, item in enumerate(items):
        try:
            results.append(process_item(item))
        except Exception as e:
            errors.append({"index": i, "item": item, "error": str(e)})

    return {"results": results, "errors": errors}