Raising Exceptions

Difficulty: Intermediate

The raise statement is how you explicitly signal that an error has occurred in your code. While Python automatically raises exceptions for built-in errors like dividing by zero, you often need to raise exceptions yourself to enforce preconditions, validate inputs, or signal that something unexpected has happened in your application logic. The basic syntax is raise ExceptionType('message'), where you instantiate an exception class with a descriptive error message.

Re-raising an exception is done with a bare raise statement inside an except block. When you catch an exception, perform some action like logging, and then want the exception to continue propagating up the call stack, you simply write raise with no arguments. This preserves the original exception with its traceback intact, which is crucial for debugging. Without re-raising, the exception is considered handled, and the program continues after the try/except block.

The raise ... from ... syntax creates explicit exception chaining. When you catch one exception and raise a different one, the original exception is attached as the __cause__ attribute of the new exception. This is useful when you want to translate a low-level exception into a domain-specific one while preserving the original error context. For example, you might catch a KeyError from a dictionary lookup and raise a ConfigurationError that provides more meaningful context to the caller.

You can also use raise ... from None to suppress the implicit exception chain. By default, when you raise an exception inside an except block, Python automatically attaches the original exception as __context__. Using from None tells Python not to display the original exception in the traceback, which is useful when the original exception is an implementation detail that would confuse the user.

Knowing when to raise exceptions versus returning error values is an important design decision. Raise exceptions for truly exceptional conditions -- situations that indicate a programming error, invalid input, or a state that the function cannot handle. Return None or a sentinel value for expected failure cases like a search that finds nothing. The general guideline is: if the caller must handle the error to proceed safely, raise an exception; if the absence of a result is a normal outcome, return a value.

Code examples

Basic raise with built-in exceptions

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age must be between 0 and 150, got {age}")
    print(f"Age set to {age}")

try:
    set_age(25)
    set_age(-5)
except ValueError as e:
    print(f"Caught ValueError: {e}")

try:
    set_age("young")
except TypeError as e:
    print(f"Caught TypeError: {e}")

The function validates its input and raises appropriate exception types with descriptive messages. The caller catches each exception type separately and can respond accordingly.

Re-raising exceptions after logging

def process_data(data):
    try:
        result = int(data)
        return result * 2
    except ValueError:
        print(f"Log: Failed to process '{data}'")
        raise  # re-raise the same exception

try:
    print(process_data("5"))
    process_data("abc")
except ValueError as e:
    print(f"Caller caught: {e}")

The bare raise statement re-raises the caught ValueError with its original message and traceback. The function logs the failure but lets the caller decide how to ultimately handle it.

Exception chaining with raise ... from

class ConfigError(Exception):
    pass

def get_config(settings, key):
    try:
        return settings[key]
    except KeyError as e:
        raise ConfigError(f"Missing required setting: {key}") from e

try:
    config = {"host": "localhost"}
    get_config(config, "port")
except ConfigError as e:
    print(f"ConfigError: {e}")
    print(f"Original cause: {e.__cause__}")

The KeyError is caught and translated into a more meaningful ConfigError. The 'from e' syntax chains the original exception as __cause__, preserving the root cause for debugging while presenting a clearer error to the caller.

Suppressing exception chain with from None

def parse_port(value):
    try:
        port = int(value)
    except ValueError:
        raise ValueError(f"Invalid port number: '{value}'") from None
    if not (1 <= port <= 65535):
        raise ValueError(f"Port {port} out of range (1-65535)")
    return port

try:
    result = parse_port("8080")
    print(f"Port: {result}")
except ValueError as e:
    print(f"Error: {e}")

try:
    parse_port("abc")
except ValueError as e:
    print(f"Error: {e}")
    print(f"Cause suppressed: {e.__cause__ is None}")

Using 'from None' creates a clean ValueError without showing the original int() conversion error in the traceback. The __cause__ is None, confirming the chain was suppressed.

Key points

Concepts covered

raise, re-raising, raise from, exception chaining, when to raise