I hear this question a lot: “Do I raise or return this error in Python?”
The right answer will depend on the goals of your application logic and the architectural context. As a software leader, ensuring robust and explicit error handling is paramount to building resilient, maintainable, and debuggable systems. You want to ensure your Python code doesn’t fail silently, saving you and your teammates from having to hunt down deeply entrenched errors in complex production environments.
TL;DR: The choice between
raise
andreturn
shapes your entire system’s resilience. Raise exceptions for true failures, return error objects for expected outcomes. Most importantly: establish consistent patterns across your team and never let errors fail silently.
This post explores the nuanced differences between raise
and return
when handling failures in Python, and how these choices impact system design and team practices.
When to raise
The
raise
statement allows the programmer to force a specific exception to occur. (8.4 Raising Exceptions)
Use raise
when an exceptional condition occurs that prevents the function from completing its intended purpose. This aligns with the “fail fast” principle, which advocates for immediate termination upon detecting an unrecoverable error. This approach helps prevent corrupted states and simplifies debugging by pinpointing the exact failure origin.
For example, if a function expects a specific type of input and receives something else, raising a TypeError
is appropriate:
raise TypeError("Wanted strawberry, got grape.")
Raising an exception terminates the current flow of your program, allowing the exception to bubble up the call stack. This enables explicit handling of TypeError
at a higher level. If TypeError
goes unhandled, code execution stops, resulting in an unhandled exception message and typically a program crash.
raise
is particularly useful for enforcing invariants or business rules:
if "raisins" in text_field:
raise ValueError("That word is not allowed here")
raise
takes an instance of an exception, or a derivative of the Exception class. Python provides a rich set of built-in exceptions.
Custom Exceptions for Clarity and Maintainability
For complex applications, defining custom exception classes is a best practice. This improves code readability, allows for more granular error handling, and provides a clear contract for consumers of your API or library.
class NoJamError(ValueError):
"""Custom exception raised when jam is missing for a sandwich."""
pass
import os
def sandwich_or_bust(bread: str) -> str:
jam = os.getenv("JAM")
if not jam:
raise NoJamError("There is no jam. Sad bread.") # Use custom exception
return bread + str(jam) + bread
s = sandwich_or_bust("\U0001F35E")
print(s)
# NoJamError: There is no jam. Sad bread.
Custom exceptions make it easier to catch specific error types without catching broader, less specific exceptions.
Any time your code interacts with an external variable, module, or service, there is a possibility of failure. You can use raise
in an if
statement to help ensure those failures aren’t silent.
Raise in try
and except
To handle a possible failure by taking an action if there is one, use a try
… except
statement. This mechanism allows for graceful recovery or specific error logging.
try:
s = sandwich_or_bust("\U0001F35E")
print(s)
except NoJamError: # Catch the specific custom exception
buy_more_jam()
raise # Re-raise to propagate the original exception
This lets you buy_more_jam()
before re-raising the exception. If you want to propagate a caught exception, use raise
without arguments to avoid possible loss of the stack trace and preserve the original error context.
While you can use a bare except:
or catch except Exception:
, it’s generally better to raise and handle exceptions explicitly by type. This prevents accidentally catching unexpected errors and makes your error handling more precise.
Use else
for code to execute if the try
does not raise an exception. For example:
try:
s = sandwich_or_bust("\U0001F35E")
print(s)
except NoJamError:
buy_more_jam()
raise
else:
print("Congratulations on your sandwich.")
You could also place the print line within the try
block, however, this is less explicit and can obscure the intended flow.
When to return
When you use return
in Python, you’re giving back a value. A function returns to the location it was called from.
While raise
is idiomatic for exceptional conditions, return
is more applicable when a function has a defined set of possible outcomes, including “failure” states that are part of its normal operational contract, rather than truly exceptional events.
For example, if your Python code is interacting with other components that do not handle exception classes, or if the “error” is a valid, expected outcome of the function’s logic, you may want to return a message or a specific error object.
Returning Error Objects or Sentinel Values
Instead of raising an exception, you might return a special value (like None
, an empty list, or a boolean False
) or a structured error object. This is common in scenarios where the caller is expected to check the return value for success or failure.
Consider a function that attempts to parse a configuration file. If the file is malformed, it might return None
or a specific error enum rather than raising an exception, especially if malformed files are a common, expected input.
from typing import Optional, Tuple
def parse_config(file_path: str) -> Optional[dict]:
"""Parses a config file, returns None if parsing fails."""
try:
# ... parsing logic ...
config_data = {"key": "value"} # Simulate successful parse
# raise ValueError("Simulate parsing error") # Uncomment to test failure
return config_data
except Exception as e:
print(f"Error parsing config: {e}")
return None
# Usage
config = parse_config("my_config.ini")
if config is None:
print("Failed to load configuration. Using defaults.")
else:
print(f"Configuration loaded: {config}")
Result Types for Explicit Error Handling
More advanced patterns involve “Result” types (e.g., Ok
or Err
variants) which explicitly encapsulate either a successful value or an error. Libraries like returns
provide such constructs, promoting functional error handling.
from returns.result import Result, Success, Failure
def divide(numerator: float, denominator: float) -> Result[float, str]:
if denominator == 0:
return Failure("Cannot divide by zero.")
return Success(numerator / denominator)
# Usage
result1 = divide(10, 2)
if result1.is_success:
print(f"Division successful: {result1.unwrap()}")
else:
print(f"Division failed: {result1.failure()}")
result2 = divide(10, 0)
if result2.is_success:
print(f"Division successful: {result2.unwrap()}")
else:
print(f"Division failed: {result2.failure()}")
This pattern forces the caller to explicitly handle both success and failure paths, reducing the chance of silent failures.
You may also use return
to give a specific error object, such as with HttpResponseNotFound
in Django. For example, you may want to return a 404
instead of a 403
for security reasons in a web application:
if object.owner != request.user:
return HttpResponseNotFound
Using return
can help you write appropriately noisy code when your function is expected to give back a certain value, and when interacting with outside elements or when the “error” is a part of the expected control flow.
Error Handling Strategies in Production Systems
Understanding how error handling impacts production systems is crucial to building maintainable products.
Logging and Observability
Effective error handling goes hand-in-hand with robust logging. When an exception is caught or an error condition is returned, it’s vital to log relevant context:
- Severity: Use appropriate log levels (e.g.,
DEBUG
,INFO
,WARNING
,ERROR
,CRITICAL
). Exceptions typically warrantERROR
orCRITICAL
. - Context: Include request IDs, user IDs, input parameters, and any other data that helps diagnose the issue.
- Stack Traces: Always log the full stack trace for exceptions.
Structured logging (e.g., JSON logs) makes it easier to parse, filter, and analyze logs in centralized logging systems.
Monitoring and Alerting
Beyond logging, critical errors should trigger alerts. This involves integrating with monitoring systems (e.g., Prometheus, Datadog, Sentry) that can:
- Count occurrences of specific exceptions.
- Alert on error rates exceeding thresholds.
- Notify on unhandled exceptions.
Proactive alerting allows teams to respond to issues before they impact users significantly.
Graceful Degradation and Resilience
In distributed systems, not all errors are fatal. Robust error handling enables graceful degradation. For instance, if a non-critical external service fails, your application might return cached data or a partial response rather than crashing entirely. This requires careful design of try...except
blocks and return
strategies to manage dependencies.
Architectural Considerations & Team Best Practices
As a leader, you guide your team in establishing consistent and effective error handling patterns.
Consistency Across the Codebase
Establish clear guidelines for when to raise
and when to return
. Inconsistent error handling leads to brittle code that is hard to debug and maintain. Document these conventions for your developers as well as your AI tools and enforce them through code reviews.
Layered Error Handling
Different layers of an application may handle errors differently:
- Presentation Layer (e.g., API endpoints): Often catches exceptions from lower layers and translates them into standardized HTTP error responses (e.g., 4xx, 5xx).
- Business Logic Layer: May raise custom exceptions for business rule violations or return
Result
types for expected outcomes. - Data Access Layer: Might translate database-specific errors into more generic application-level exceptions or return
None
for “not found” scenarios.
Error Contracts and Documentation
For public APIs or shared libraries, explicitly document the errors that can be raised or returned. This forms an “error contract” that consumers can rely on, similar to how function signatures define input and output types.
Testing Error Paths
Thorough testing includes validating error handling. Unit tests should verify that functions raise the correct exceptions or return the expected error values under various failure conditions. Integration tests should confirm that errors propagate correctly through system layers.
The Most Important Part
Silent failures create some of the most frustrating bugs to find and fix, especially at scale. As a senior engineer or leader, your responsibility extends beyond just writing functional code to designing systems that are resilient, observable, and easy to debug. By intentionally choosing between raise
and return
, defining custom exceptions, and implementing comprehensive logging, monitoring, and consistent architectural patterns, you ensure that errors are handled explicitly and effectively in your Python code.
If you found some value in this post, there’s more! I write about high-output development processes and building maintainable systems in the AI age. You can get my posts in your inbox by subscribing below.