Python Error Handling: try-except Blocks, Logging Levels, and Exception Patterns
Everything You Want to Know About Python Error Handling
Effective error handling is the foundation of writing robust, maintainable, and reliable Python code. It ensures that your application can gracefully manage unexpected conditions without crashing, providing clean feedback to the user or logging useful data for debugging.
This article details the comprehensive toolkit Python provides for managing errors, covering structure, best practices, and advanced techniques.
1. The Core Structure: try, except, else, and finally
Python uses a specific block structure to define areas of code that should be monitored for exceptions and how to respond if they occur.
| Block | Purpose | Rule |
|---|---|---|
try | Contains the code that might raise an exception. | Execution starts here. If an exception occurs, execution immediately jumps to except. |
except | Defines the action to take when a specific exception type is caught. | Must follow try. Can be specific (except ValueError) or general (except Exception). |
else | Contains code that should run only if the try block completes successfully (no exceptions). | Must follow try and all except blocks. Excellent for validation checks. |
finally | Contains cleanup code that always runs, regardless of whether an exception occurred or was handled. | Always runs. Useful for closing files, releasing locks, or ensuring resources are cleaned up. |
Code Example: The Full Structure
def safe_divide(a, b):
try:
# Code that might fail (e.g., ZeroDivisionError)
result = a / b
except ZeroDivisionError:
# Code runs if ZeroDivisionError occurs
print("Error: Cannot divide by zero.")
return None
except TypeError:
# Code runs if TypeError occurs
print("Error: Operands must be numbers.")
return None
else:
# Code runs ONLY if no exception was raised in 'try'
print("Division successful.")
return result
finally:
# Code always runs (cleanup, auditing, etc.)
print("--- Execution finished for this attempt. ---")
safe_divide(10, 2)
# Output: Division successful. ... Execution finished...
safe_divide(10, 0)
# Output: Error: Cannot divide by zero. ... Execution finished...
2. Best Practice: Specific vs. General Exceptions
A common mistake is using a generic except block, which can hide subtle bugs or interrupt keyboard interrupts (KeyboardInterrupt is an exception).
Avoid: Catching Too Broadly
Catching Exception is often too broad, as it handles nearly all runtime errors, including those you may not have anticipated.
try:
data = fetch_data()
# ... potentially complex code ...
except Exception as e:
# This might hide an unrelated KeyError or AttributeError!
print(f"An unknown error occurred: {e}")
Best Practice: Catch Specific Errors
Always aim to catch the narrowest exception necessary. If multiple errors can occur, use multiple except blocks.
try:
user_id = lookup_user(username) # Might raise KeyError if user not found
process_data(user_id) # Might raise ConnectionError if API fails
except KeyError:
# Handle missing user specifically
logger.warning("Attempted lookup for non-existent user.")
except ConnectionError:
# Handle network failure specifically
logger.error("Failed to connect to external service.")
except Exception as e:
# ONLY use the general Exception as a final safety net for *truly* unexpected errors
logger.critical(f"Unhandled error: {e}")
3. Raising Custom Exceptions
The most powerful way to signal a specific problem in a library or application is to raise a custom exception. This provides a clear, domain-specific signal that upstream code can catch reliably.
Custom exceptions should inherit from the built-in Exception class or a more specific base class.
# 1. Define a base exception for your application/domain
class DataProcessingError(Exception):
"""Base exception for all data processing failures."""
pass
# 2. Define a more specific exception
class InvalidSchemaError(DataProcessingError):
"""Raised when data structure does not match expected schema."""
def __init__(self, key, expected_type):
self.key = key
self.expected_type = expected_type
super().__init__(f"Key '{key}' failed schema check. Expected {expected_type}.")
def validate_data(data):
if 'id' not in data:
# 3. Raise the custom exception
raise InvalidSchemaError('id', 'int')
# ...
try:
validate_data({'name': 'Test'})
except InvalidSchemaError as e:
print(f"Validation failure for key: {e.key}") # Accessing exception attributes
4. Advanced: with Statements and Context Managers
The with statement leverages Context Managers to ensure that resources are handled correctly, simplifying the use of try...finally.
When the with block is exited (either normally or due to an exception), the context manager's exit method is guaranteed to run, ensuring cleanup.
Code Example: Guaranteed Resource Cleanup
# Instead of:
# f = open('file.txt', 'r')
# try:
# data = f.read()
# except Exception:
# pass
# finally:
# f.close() # Manual cleanup
# Use the cleaner 'with' statement:
with open('data.txt', 'r') as f:
# File is guaranteed to be closed, even if f.read() raises an exception
data = f.read()
print("File reading successful.")
# f.close() is automatically called here
5. Propagating and Chaining Exceptions
Sometimes, you catch an exception but need to raise a different one while keeping the original context (traceback). This is called exception chaining.
raise ... from ...: Explicitly links the new exception to the original cause.- Implicit Chaining: If an exception is raised inside an
exceptorfinallyblock, the original exception is automatically chained to the new one.
class ExternalAPIFailure(Exception): pass
def call_api(url):
try:
# Third-party library raises ConnectionError
response = requests.get(url, timeout=1)
except requests.exceptions.ConnectionError as e:
# Re-raise as a custom, domain-specific exception, retaining the original trace.
raise ExternalAPIFailure(f"API at {url} is unreachable.") from e
When this code fails, the traceback will show both the ExternalAPIFailure and the underlying requests.exceptions.ConnectionError that caused it.
Sources and Further Reading
- Python Documentation - Errors and Exceptions
- Python Documentation - Context Managers
- Real Python - Python Exception Handling
When to Use Multiple try-except Blocks
Using multiple separate try-except blocks (rather than a single block catching multiple exception types) is essential when:
- Independent Operations: Step B should execute even if Step A fails or has a fallback.
- Granular Cleanup: Different resources (files, DB connections, API sockets) require specific cleanup handlers.
- Distinct Recovery Strategies: A missing config file requires loading defaults, whereas a network timeout requires retrying the HTTP request.
# Separate try-except blocks for independent recovery
try:
with open("config.json") as f:
config = json.load(f)
except FileNotFoundError:
config = {"timeout": 30} # Fallback default
try:
response = requests.get("https://api.example.com", timeout=config["timeout"])
except requests.exceptions.RequestException as e:
logger.error("API call failed: %s", e)
Using Enums for Python Logging Levels
For type-safe logging management, wrapping logging levels in Python Enum provides auto-completion and prevents magic integer numbers:
import logging
from enum import IntEnum
class LogLevel(IntEnum):
DEBUG = logging.DEBUG
INFO = logging.INFO
WARNING = logging.WARNING
ERROR = logging.ERROR
CRITICAL = logging.CRITICAL
logging.basicConfig(level=LogLevel.INFO)
logging.log(LogLevel.WARNING, "System resources reaching capacity.")
