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 # ZeroDivisionErrorBasic 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, NoneMultiple 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
raise3. 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 eContext 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)
}
)
raiseTesting 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
- Python Error Handling Documentation
- Logging HOWTO
- Effective Python Exception Handling
- Clean Code Exception Handling
Next Steps
Now that you understand error handling, explore testing your Python applications to ensure your error handling works correctly.
Last updated on