Everything You Want to Know About Python Error Handling
π¨ 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.
