Python - Exception Handling (try/except/finally)
Python's exception handling mechanism separates normal code flow from error handling logic. The try block contains code that might raise exceptions, while except blocks catch and handle specific...
Key Insights
- Exception handling in Python uses try/except/finally blocks to gracefully manage runtime errors, preventing application crashes and enabling controlled error recovery with specific exception types for precise error handling.
- The finally block executes regardless of whether an exception occurs, making it essential for cleanup operations like closing files, network connections, or releasing resources that must happen in all code paths.
- Context managers (with statements) often provide cleaner alternatives to try/finally for resource management, while custom exceptions enable domain-specific error handling that makes code more maintainable and self-documenting.
Basic Exception Handling Structure
Python’s exception handling mechanism separates normal code flow from error handling logic. The try block contains code that might raise exceptions, while except blocks catch and handle specific errors.
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: Invalid input types")
return None
# Usage
print(divide_numbers(10, 2)) # 5.0
print(divide_numbers(10, 0)) # Error: Cannot divide by zero, None
print(divide_numbers(10, "2")) # Error: Invalid input types, None
Multiple except blocks allow handling different exception types with specific logic. Python evaluates except clauses from top to bottom, executing the first match.
Catching Multiple Exceptions
Group related exceptions in a single except clause using tuples when the handling logic is identical.
def read_and_process_file(filename):
try:
with open(filename, 'r') as f:
data = f.read()
return int(data.strip())
except (FileNotFoundError, PermissionError) as e:
print(f"File access error: {e}")
return None
except ValueError as e:
print(f"Invalid data format: {e}")
return None
# Example with proper error context
result = read_and_process_file("data.txt")
if result is not None:
print(f"Processed value: {result}")
The as keyword captures the exception instance, providing access to error details like messages and attributes.
The Finally Block for Cleanup
The finally block executes whether an exception occurs or not, making it ideal for cleanup operations that must run unconditionally.
def process_database_transaction(query):
connection = None
try:
connection = get_database_connection()
cursor = connection.cursor()
cursor.execute(query)
connection.commit()
return True
except DatabaseError as e:
if connection:
connection.rollback()
print(f"Database error: {e}")
return False
finally:
if connection:
connection.close()
print("Database connection closed")
# The connection closes regardless of success or failure
This pattern ensures resources are released even if exceptions occur or early returns execute.
Exception Hierarchy and Broad Catching
Python’s exception hierarchy allows catching base classes to handle multiple related exceptions. However, catch specific exceptions first, then broader ones.
def safe_api_call(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
except requests.Timeout:
print("Request timed out")
return None
except requests.HTTPError as e:
print(f"HTTP error occurred: {e.response.status_code}")
return None
except requests.RequestException as e:
# Catches all requests-related exceptions
print(f"Request failed: {e}")
return None
except Exception as e:
# Last resort - catches unexpected errors
print(f"Unexpected error: {e}")
return None
Avoid bare except: clauses as they catch system exits and keyboard interrupts, making debugging difficult.
Raising Exceptions
Raise exceptions explicitly to signal error conditions that calling code should handle. Re-raise caught exceptions after logging or cleanup.
def validate_user_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age exceeds reasonable maximum")
return True
def process_user_data(user_data):
try:
validate_user_age(user_data['age'])
# Process valid data
except (TypeError, ValueError) as e:
print(f"Validation failed: {e}")
# Log error, notify user, etc.
raise # Re-raise to let caller handle it
Re-raising preserves the original traceback, essential for debugging.
Custom Exceptions
Define custom exception classes for domain-specific errors, making code more readable and enabling precise exception handling.
class InsufficientFundsError(Exception):
"""Raised when account balance is insufficient for transaction"""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient funds: balance={balance}, required={amount}")
class AccountLockedError(Exception):
"""Raised when attempting to access a locked account"""
pass
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
self.locked = False
def withdraw(self, amount):
if self.locked:
raise AccountLockedError("Account is locked")
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return self.balance
# Usage with specific exception handling
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(f"Cannot withdraw: {e}")
print(f"Available: {e.balance}, Requested: {e.amount}")
except AccountLockedError as e:
print(f"Access denied: {e}")
Custom exceptions inherit from Exception or more specific base classes, providing semantic clarity.
Else Clause in Try Blocks
The else clause executes only if no exception occurs in the try block, separating successful execution code from the try block.
def load_configuration(filename):
try:
with open(filename, 'r') as f:
config = json.load(f)
except FileNotFoundError:
print(f"Config file {filename} not found, using defaults")
config = get_default_config()
except json.JSONDecodeError as e:
print(f"Invalid JSON in config file: {e}")
config = get_default_config()
else:
print(f"Successfully loaded configuration from {filename}")
validate_config(config) # Only runs if file loaded successfully
finally:
print("Configuration loading complete")
return config
The else clause makes code flow clearer by distinguishing between exception handling and normal success paths.
Context Managers vs Try/Finally
Context managers using the with statement provide cleaner resource management than try/finally blocks.
# Traditional try/finally approach
def read_file_traditional(filename):
f = None
try:
f = open(filename, 'r')
content = f.read()
return content
finally:
if f:
f.close()
# Context manager approach (preferred)
def read_file_context(filename):
with open(filename, 'r') as f:
content = f.read()
return content
# Custom context manager for complex cleanup
from contextlib import contextmanager
@contextmanager
def database_transaction(connection):
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
# Usage
with database_transaction(get_connection()) as conn:
cursor = conn.cursor()
cursor.execute("INSERT INTO users VALUES (?)", (user_data,))
Context managers guarantee cleanup even with early returns or exceptions, reducing boilerplate code.
Exception Chaining
Python 3 supports exception chaining to preserve context when raising new exceptions during exception handling.
def parse_user_input(input_string):
try:
data = json.loads(input_string)
return data
except json.JSONDecodeError as e:
raise ValueError("Invalid user input format") from e
def process_request(request_data):
try:
parsed = parse_user_input(request_data)
return parsed
except ValueError as e:
print(f"Error: {e}")
print(f"Caused by: {e.__cause__}")
# Full traceback shows both exceptions
Use raise ... from ... to explicitly chain exceptions, maintaining the full error context for debugging.
Practical Error Handling Patterns
Combine exception handling techniques for robust error management in production code.
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def fetch_user_data(user_id: int) -> Optional[dict]:
"""Fetch user data with comprehensive error handling"""
try:
# Validate input
if not isinstance(user_id, int) or user_id <= 0:
raise ValueError(f"Invalid user_id: {user_id}")
# Attempt to fetch data
response = api_client.get(f"/users/{user_id}", timeout=10)
response.raise_for_status()
except ValueError as e:
logger.error(f"Validation error: {e}")
return None
except requests.Timeout:
logger.warning(f"Timeout fetching user {user_id}")
return None
except requests.HTTPError as e:
if e.response.status_code == 404:
logger.info(f"User {user_id} not found")
else:
logger.error(f"HTTP error: {e.response.status_code}")
return None
except Exception as e:
logger.exception(f"Unexpected error fetching user {user_id}")
return None
else:
logger.info(f"Successfully fetched user {user_id}")
return response.json()
This pattern validates inputs, handles specific errors appropriately, logs context, and returns consistent values for both success and failure cases.