Blog

Handling Errors in Python: Exceptions vs. Error Objects

Dealing with errors and exceptional conditions is a fundamental aspect of programming, particularly in languages like Python. When writing functions that may encounter various failure states, developers face the decision of whether to raise exceptions or to return error objects. Each approach has its advantages, trade-offs, and implications for code clarity, robustness, and maintainability. Understanding these options helps create a more predictable and developer-friendly API, especially in complex systems such as email verification workflows or application logic.

Returning Error Objects Versus Raising Exceptions

In Python, the traditional way to handle errors is through exceptions—raising them when something goes wrong. Alternatively, functions can return error objects or sentinel values indicating failure, allowing the caller to handle different cases explicitly. Both strategies aim to ensure that errors are not silently ignored and that the code behaves predictably under adverse conditions.

For example, consider a system designed for verifying email addresses through secure tokens. Such a system might need to distinguish between a successfully verified email, an expired token, or an invalid signature. The core question is: should the verification function raise distinct exceptions for each failure mode, or should it return specific error objects that encapsulate the failure details?

The Approach of Returning Error Objects

Returning error objects involves defining specialized classes or data structures to represent different failure states. This method aligns with the principle of “Parse Don’t Validate,” emphasizing that functions should parse and validate input while encoding the result’s nature in the return type. For example, using Python’s `dataclasses`, you can create a structure like:

“`python

from dataclasses import dataclass

@dataclass

class VerifyExpired:

email: str

VerifyFailed = object() # singleton sentinel value

“`

The verification method then returns either a valid email string or one of these error objects. This approach provides several benefits:

  • Explicit Handling: The caller must examine the returned value’s type, encouraging deliberate handling of all possible outcomes.
  • Type Safety and Documentation: Using type annotations (e.g., `Union[str, VerifyExpired, VerifyFailed]`) enhances readability and IDE support.
  • Structural Pattern Matching: Python 3.10+ supports pattern matching, enabling clean, readable handling of different return types:

“`python

match result:

case str() as email:

# process verified email

case VerifyExpired(email):

# handle expired token

case VerifyFailed:

# handle failure

“`

  • Integration with Static Type Checkers: Tools like `mypy` can enforce exhaustiveness, catching unhandled cases at compile time.

The Exception-Raising Alternative

Alternatively, the function can raise exceptions for error states:

“`python

class InvalidSignature(Exception):

pass

class TokenExpired(Exception):

pass

def email_from_token(token: str) -> str:

# validation logic

if signature_invalid:

raise InvalidSignature()

if token_expired:

raise TokenExpired()

return extracted_email

“`

This approach simplifies the success path but requires callers to use `try/except` blocks:

“`python

try:

email = verifier.email_from_token(token)

except InvalidSignature:

# handle invalid signature

except TokenExpired:

# handle expiration

“`

While exceptions can lead to cleaner code when errors are truly exceptional, they can also complicate control flow and make handling multiple outcomes less straightforward, especially when the failure states are common or expected.

Benefits of Returning Error Objects

Choosing to return error objects rather than raising exceptions offers distinct advantages, especially when combined with modern Python features:

  • Unified Control Flow: All results are handled in a single code path, simplifying logic and reducing boilerplate.
  • Better IDE Support and Documentation: Explicit return types make it clear what each function can produce, improving code comprehension.
  • Enhanced Static Analysis: Type checkers can verify that all error cases are handled, reducing bugs.
  • Pattern Matching: Structural pattern matching enables concise, readable handling of different outcomes, including destructuring data when necessary.
  • Flexibility: Error objects can carry additional context or metadata, aiding in detailed logging or user messaging.

For example, in a token verification system, you might implement:

“`python

from typing import Union

from typing_extensions import assert_never

def email_from_token(token: str, max_age: int = None) -> Union[str, VerifyFailed, VerifyExpired]:

# verification logic

if invalid_signature:

return VerifyFailed

elif expired:

return VerifyExpired(email)

else:

return email

result = verifier.email_from_token(token)

if isinstance(result, VerifyFailed):

# handle failure

elif isinstance(result, VerifyExpired):

# handle expiration

else:

# process verified email

“`

This pattern encourages explicit handling of each case, reducing the risk of subtle bugs.

Inspiration from Functional Languages

Languages like Haskell exemplify this approach with algebraic data types, which naturally encode multiple possible outcomes in a single return type. For instance:

“`haskell

data EmailVerificationResult = EmailVerified String | VerifyFailed | VerifyExpired String

“`

This design makes it impossible to forget handling any case due to static type checking, leading to more robust code. Python’s `dataclasses` and type annotations allow similar patterns, providing many of the same benefits in a dynamically typed language.

Practical Considerations and When to Use Exceptions

While error objects are advantageous for predictable, expected failures—such as expired tokens—they are not always suitable. Exception handling remains appropriate when errors are truly unexpected or indicate critical issues, like system failures or configuration errors. In such cases, exceptions can clearly signal that something is wrong beyond normal control flow.

Ultimately, the decision depends on the context and the nature of the failure modes. For email verification systems or similar workflows, returning detailed error objects often leads to clearer, more maintainable code, especially when combined with modern Python features like pattern matching and static type analysis.

Links