- Item 30: Know That Function Arguments Can Be Mutated
- Item 31: Return Dedicated Result Objects Instead of Requiring Function Callers to Unpack More Than Three Variables
- Item 32: Prefer Raising Exceptions to Returning None
- Item 33: Know How Closures Interact with Variable Scope and nonlocal
- Item 34: Reduce Visual Noise with Variable Positional Arguments
- Item 35: Provide Optional Behavior with Keyword Arguments
- Item 36: Use None and Docstrings to Specify Dynamic Default Arguments
- Item 37: Enforce Clarity with Keyword-Only and Positional-Only Arguments
- Item 38: Define Function Decorators with functools.wraps
- Item 39: Prefer functools.partial over lambda Expressions for Glue Functions
Item 32: Prefer Raising Exceptions to Returning None
When writing utility functions, there’s a draw for Python programmers to give special meaning to the return value None. It seems to make sense in some cases (see Item 26: “Prefer get over in and KeyError to Handle Missing Dictionary Keys”). For example, say I want a helper function that divides one number by another. In the case of dividing by zero, returning None seems natural because the result is undefined:
def careful_divide(a, b): try: return a / b except ZeroDivisionError: return None
Code that uses this function can interpret the return value accordingly:
x, y = 1, 0 result = careful_divide(x, y) if result is None: print("Invalid inputs")
What happens with the careful_divide function when the numerator is zero? If the denominator is not zero, then the function returns zero. The problem is that a zero return value can cause issues when you evaluate the result in a condition like an if statement. You might accidentally look for any falsey value to indicate errors instead of only looking for None (see Item 4: “Write Helper Functions Instead of Complex Expressions” and Item 7: “Consider Conditional Expressions for Simple Inline Logic”):
x, y = 0, 5 result = careful_divide(x, y) if not result: # Changed print("Invalid inputs") # This runs! But shouldn't >>> Invalid inputs
This misinterpretation of a False-equivalent return value is a common mistake in Python code when None has special meaning. This is why returning None from a function like careful_divide is error prone. There are two ways to reduce the chance of such errors.
The first way is to split the return value into a two-tuple (see Item 31: “Return Dedicated Result Objects Instead of Requiring Function Callers to Unpack More Than Three Variables” for background). The first part of the tuple indicates that the operation was a success or failure. The second part is the actual result that was computed:
def careful_divide(a, b): try: return True, a / b except ZeroDivisionError: return False, None
Callers of this function have to unpack the tuple. That forces them to consider the status part of the tuple instead of just looking at the result of division:
success, result = careful_divide(x, y) if not success: print("Invalid inputs")
The problem is that callers can easily ignore the first part of the tuple (using the underscore variable name, which is a Python convention for unused variables). The resulting code doesn’t look wrong at first glance, but this can be just as error prone as returning None:
_, result = careful_divide(x, y) if not result: print("Invalid inputs")
The second, better way to reduce these errors is to never return None for special cases. Instead, raise an exception up to the caller and have the caller deal with it. Here, I turn ZeroDivisionError into ValueError to indicate to the caller that the input values are bad (see Item 88: “Consider Explicitly Chaining Exceptions to Clarify Tracebacks” and Item 121: “Define a Root Exception to Insulate Callers from APIs” for details):
def careful_divide(a, b): try: return a / b except ZeroDivisionError: raise ValueError("Invalid inputs") # Changed
The caller no longer requires a condition on the return value of the function. Instead, it can assume that the return value is always valid and use the results immediately in the else block after try (see Item 80: “Take Advantage of Each Block in try/except/else/finally” for background):
x, y = 5, 2 try: result = careful_divide(x, y) except ValueError: print("Invalid inputs") else: print(f"Result is {result:.1f}") >>> Result is 2.5
This approach can be extended to code using type annotations (see Item 124: “Consider Static Analysis via typing to Obviate Bugs” for background). You can specify that a function’s return value will always be a float and thus will never be None. However, Python’s gradual typing purposely doesn’t provide a way to indicate when exceptions are part of a function’s interface (also known as checked exceptions). Instead, you have to document the exception-raising behavior and expect callers to rely on that in order to know which exceptions they should plan to catch (see Item 118: “Write Docstrings for Every Function, Class, and Module”).
Pulling it all together, here’s what this function should look like when using type annotations and docstrings:
def careful_divide(a: float, b: float) -> float: """Divides a by b. Raises: ValueError: When the inputs cannot be divided. """ try: return a / b except ZeroDivisionError: raise ValueError("Invalid inputs") try: result = careful_divide(1, 0) except ValueError: print("Invalid inputs") # Expected else: print(f"Result is {result:.1f}") >>> $ python3 -m mypy --strict example.py Success: no issues found in 1 source file
Now the inputs, outputs, and exceptional behavior are all clear, and the chance of a caller doing the wrong thing is extremely low.
Things to Remember
Functions that return None to indicate special meaning are error prone because None and many other values, such as zero and empty strings, evaluate to False in Boolean expressions.
Raise exceptions to indicate special situations instead of returning None. Expect the calling code to handle exceptions properly when they’re documented.
Type annotations can be used to make it clear that a function will never return the value None, even in special situations.