39.2 raise: Raising Exceptions and Re-Raising
The raise statement is the mechanism by which an exception is explicitly triggered in Python. It interrupts the normal flow of the program and transfers control to the nearest enclosing exception handler. Understanding its nuances is critical for writing robust code that can handle both expected and unexpected error conditions.
The Basic Syntax of raise
The simplest form of the raise statement is to use it with an exception instance. You can create the instance directly within the statement. The first argument to the exception class is the error message, which provides crucial context for debugging.
def calculate_square_root(x):
if x < 0:
# Create and raise a ValueError instance
raise ValueError("Cannot calculate square root of a negative number.")
return x ** 0.5
try:
result = calculate_square_root(-9)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Cannot calculate square root of a negative number.
It is also permissible, though less common, to raise the exception class itself. When you do this, Python automatically creates an instance of the class with no arguments. This is why some built-in exceptions, like RuntimeError, don’t require a message, though providing one is almost always better practice.
def start_engine():
if fuel_level == 0:
# Python implicitly creates RuntimeError()
raise RuntimeError
Re-raising Exceptions with a Blank raise
A powerful and often misunderstood feature is the ability to re-raise a currently handled exception. This is done using a bare raise statement inside an except block. This technique is vital when you need to perform a partial local handling of an error (like logging it or rolling back a temporary change) but then allow the exception to continue propagating up the call stack to be handled more comprehensively elsewhere.
def risky_operation():
try:
# Code that might fail, e.g., accessing a file
with open('non_existent_file.txt', 'r') as f:
data = f.read()
except FileNotFoundError:
# Log the error for debugging purposes here
print("Log: Attempted to open a file that doesn't exist.")
# Re-raise the same exception to let the caller handle it
raise
try:
risky_operation()
except FileNotFoundError as e:
print(f"Main program caught: {e}")
The critical thing to understand here is that the re-raised exception is the original exception. The traceback will point to the line in risky_operation where the open() call failed, not to the line containing the bare raise. This preserves the original debugging context, which is a major advantage over raising a new exception with the original one as a cause.
Raising a New Exception from a Caught One (Chaining)
Sometimes, when catching a low-level exception, you want to raise a different, more application-specific exception. The best practice is to use the from keyword to explicitly chain the exceptions. This creates an unbreakable link between the two: the original exception becomes the __cause__ of the new exception. This is explicit exception chaining.
class DatabaseConnectionError(Exception):
"""Custom exception for application-level errors."""
def connect_to_database():
try:
# Simulate a low-level OS error
raise OSError("Can't connect to network socket")
except OSError as original_error:
# Raise a higher-level error, chaining the original one
raise DatabaseConnectionError("Failed to connect to the database") from original_error
try:
connect_to_database()
except DatabaseConnectionError as e:
print(f"Caught: {e}")
print(f"Original cause: {e.__cause__}") # Output: Can't connect to network socket
When this code runs, the output will show both exceptions, making the root cause immediately clear to the developer: DatabaseConnectionError: Failed to connect to the database followed by OSError: Can't connect to network socket. This is far superior to simply raising DatabaseConnectionError(original_error.message) as it loses the original exception type and traceback.
Common Pitfalls and Best Practices
A common anti-pattern is to raise a new exception without chaining inside an except block. This severs the connection to the original error, making debugging significantly harder because the true root cause is buried and the original traceback is lost.
# DON'T DO THIS
try:
with open('file.txt') as f:
process(f)
except OSError:
# The original OSError and its traceback are lost here.
raise MyCustomError("Something went wrong with I/O")
Best Practice: Almost always use explicit chaining (raise NewError from e) when translating exceptions. The blank raise should be reserved for when you are not changing the exception type.
Another critical best practice is to ensure exception messages are clear and actionable. The message should precisely state what went wrong and potentially why. Avoid raising generic exceptions like RuntimeError or ValueError when a more specific built-in or custom exception would be more appropriate. For instance, raise TypeError for wrong argument types or ValueError for correct types but invalid values. Finally, remember that raise is a statement, not a function, so parentheses are only needed if creating an instance with arguments.