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
| Exception | When Raised |
|---|---|
ValueError | Wrong value (e.g., int("abc")) |
TypeError | Wrong type (e.g., "2" + 2) |
KeyError | Dict key not found |
IndexError | List index out of range |
AttributeError | Attribute not found |
NameError | Variable not defined |
FileNotFoundError | File doesn't exist |
IOError | I/O operation failed |
ZeroDivisionError | Division by zero |
ImportError | Import failed |
StopIteration | Iterator exhausted |
RuntimeError | General runtime error |
NotImplementedError | Method 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}