Return Values from Python Decorators

Learn how to preserve, inspect, and transform return values in Python decorators, and why the wrapper must always return the result of calling the original function.

7 min read

Handling return values from Python decorators correctly is critical because a wrapper that forgets to return the original function's result is one of the most common and hardest-to-spot decorator bugs in Python. The decorated function is called, the wrapper runs without error, and the original function executes and computes its result, but the caller receives None instead of the expected value. No exception is raised, no traceback appears, and the bug manifests silently as downstream code that receives None where it expected a number, a string, or a data structure. This article explains how return value handling works in decorator wrappers, how to preserve values correctly, and how to inspect or transform them when the decorator's purpose requires it.

The previous article on decorating Python functions with arguments established that a decorator wrapper receives arguments through star-args and double-star-kwargs and forwards them to the original function. The other half of the call contract is the return value. When the original function finishes, Python places its return value on the call stack, and the wrapper must propagate that value back to the caller. The minimal correct pattern captures the result of the original function call in a variable and returns it. This sounds obvious, but it is easy to forget when the wrapper includes its own logic before and after the call. The wrapper's return statement is the last thing that executes, and if it returns something other than the original function's result, the caller sees that other thing instead.

The relationship between the wrapper and the original function's return value is not merely about preservation. A decorator can inspect the return value for logging or validation, much as it can inspect arguments before the call. A decorator can transform the return value, converting it to a different type or wrapping it in additional structure. And a decorator can conditionally suppress the original function call and return a cached or synthetic value instead. Each of these patterns depends on the wrapper having full control over what gets returned, which is why Python's decorator model gives the wrapper the final say over what the caller receives.

The simplest correct wrapper

Every decorator wrapper that is meant to be transparent to the caller should follow this template at minimum:

pythonpython
def my_decorator(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

The line that assigns result captures the return value of calling the original function with all received arguments. The subsequent return line sends that value back to the caller. If the original function returns an integer, the caller receives that integer. If it returns a list, the caller receives that list. If it returns None, the caller receives None. The wrapper adds no behaviour of its own, so the decorated function is indistinguishable from the original, which is the correct starting point for building wrappers that do add behaviour.

The buggy version of this wrapper omits the return statement and calls the original function without capturing or returning its result. This wrapper calls the original function and discards its return value. As covered in return values in Python functions, a function that reaches the end without a return statement returns None implicitly, so the caller of a function decorated with this broken wrapper always receives None. If the original function was supposed to compute and return a value, that value is lost. The original function runs, its side effects (like printing or writing to a file) still happen, but any code that depends on its return value breaks. A function decorated this way inside a larger expression like adding the result to tax assigns None plus tax, which raises a TypeError with a confusing message far from the actual bug.

Adding behaviour while preserving the return value

Real decorators add behaviour before the call, after the call, or both, and they must do so without interfering with the return value. The timing decorator from earlier articles is the canonical example of adding behaviour on both sides of the call while preserving the result:

pythonpython
import time
 
def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} ran in {end - start:.4f}s")
        return result
    return wrapper

The wrapper captures the return value in a variable called result before printing the timing message, then returns result at the end. If the wrapper printed the message and then returned the original function call directly without capturing it, the timing would still be correct and the return value would still reach the caller, but the print would appear after the function returned its value. For a timing decorator that distinction does not matter, but for decorators that need to inspect or modify the return value before the caller sees it, capturing it in a variable is necessary.

A logging decorator that records both the arguments and the return value follows the same pattern, capturing the result so it can be logged and then returned:

pythonpython
def log_calls(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__}({args}, {kwargs}) -> {result}")
        return result
    return wrapper

Notice that the log message is printed after the call rather than before, which means the wrapper logs the return value alongside the arguments. If the log message were printed before the call, it could only log the arguments, because the return value would not yet exist. This is a design choice: a pre-call log confirms that a function was invoked, which is useful for tracing control flow, while a post-call log confirms what the function returned, which is useful for debugging data transformations. A decorator can log both by printing before and after the call.

Inspecting return values for validation

A decorator can check the return value against expectations and raise an error if the value does not meet them. This is less common than argument validation, but it is useful when a function's output feeds into a system that has strict requirements. For example, a decorator that ensures a function never returns None unless None is explicitly allowed:

pythonpython
def require_not_none(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        if result is None:
            raise ValueError(
                f"{func.__name__} returned None, which is not allowed"
            )
        return result
    return wrapper

This decorator calls the original function, checks whether the return value is None, and raises a clear ValueError if it is. The error message includes the function name, which helps the developer identify which function produced the unacceptable None without reading a traceback. The decorator returns the result normally if it passes the check.

Return value validation decorators are particularly valuable in data processing pipelines where one function's output becomes the next function's input. If a function in the middle of a pipeline unexpectedly returns None, the next function fails with an opaque AttributeError or TypeError that does not indicate where the None originated. A validation decorator catches the None at its source and provides a message that names the responsible function, which saves debugging time in complex pipelines.

Transforming return values

Some decorators exist specifically to transform the return value of the decorated function. A decorator that converts a function's return value from one type to another, or wraps it in a container, or enriches it with metadata, is making a deliberate change to the function's contract. This is different from preserving the return value transparently, and it should be documented so callers know what to expect.

A common transformation pattern is a decorator that ensures a function always returns a list, even when the function itself returns a single value or a different iterable type. This is useful when the caller expects to iterate over the result uniformly. The wrapper checks the return type: if it is already a list, it returns unchanged; if it is a tuple or set, it converts to a list; if it is a single scalar value, it wraps it in a single-element list. Callers of the decorated function can safely iterate without checking whether they received a sequence or a scalar.

Another transformation pattern is a decorator that wraps the return value in a result object that indicates success or failure, similar to Result types in languages like Rust. This is not a built-in Python pattern, but it is common in larger codebases that want to avoid exceptions for control flow. The wrapper catches any exception the original function raises and returns a dictionary that indicates success or failure instead of allowing the exception to propagate. The caller checks a success flag to determine whether the call succeeded, and retrieves either the value or the error message.

The interaction with multiple decorators

When multiple decorators are stacked on a single function, the return value passes through each wrapper in reverse order of decoration. If a function is decorated with decorator_a on top of decorator_b, the call reaches decorator_a's wrapper first, which calls decorator_b's wrapper, which calls the original function. The return value travels back up: the original function's result goes to decorator_b's wrapper, which may inspect or transform it, and then decorator_b's wrapper returns a value to decorator_a's wrapper, which may inspect or transform it again before returning to the caller.

This stacking behaviour means that a return-value transformation applied by an inner decorator is visible to an outer decorator. If decorator_b transforms the return value from an integer to a string, decorator_a's wrapper sees a string when it inspects the result. This is usually the desired behaviour because each decorator operates on the output of the decorator below it, but it can cause surprises when the outer decorator expects a specific type.

The stacking order also affects decorators that use return values for their own logic. A timing decorator that records the duration of the call should be placed as close to the original function as possible, so it measures only the original function's execution time. If the timer is on top, it measures the combined time of the original function plus any inner decorators, which may or may not be what you want. Understanding how return values propagate through stacked decorators helps you place each decorator in the correct position.

Once return values are handled correctly, the next article moves to a more advanced pattern: Python decorators with parameters, where the decorator itself accepts arguments that configure its behaviour. This introduces an additional layer of function nesting, but the principles of argument forwarding and return value preservation remain the same.

Rune AI

Rune AI

Key Insights

  • Every decorator wrapper must capture and return the result of calling the original function, or the decorated function silently returns None.
  • A decorator can inspect the return value for logging, validation, or transformation before returning it to the caller.
  • Return value transformation is less common than argument modification but is the right tool for decorators that normalize output or wrap results.
  • Functions that return None are valid and common; a decorator should handle them gracefully rather than assuming a meaningful return value.
  • Testing a decorator with a function that returns a value is the simplest way to catch the missing-return bug before it ships.
RunePowered by Rune AI

Frequently Asked Questions

Why does a decorator wrapper need to return the result of the original function?

The wrapper replaces the original function, so any code that calls the decorated function expects to receive the return value. If the wrapper does not return the result of calling the original function, the decorated function silently returns None instead of its actual computed value. This is one of the most common and hardest-to-spot decorator bugs because no error is raised, but downstream code receives None where it expects a real value.

Can a decorator change the return value of a function?

Yes. A decorator wrapper can call the original function, capture its return value, transform it, and return the transformed value instead. This is useful for decorators that normalize output, wrap results in a container, convert between data formats, or add metadata to the return value. The transformation should be documented because the caller may depend on a specific return type.

Conclusion

Handling return values correctly is the detail that completes a decorator. A wrapper that receives arguments and calls the original function but forgets to return the result is a broken decorator, and the breakage is silent. Every wrapper should capture the result of calling the original, optionally inspect or transform it, and return it. When you follow this rule consistently, your decorated functions behave as drop-in replacements for the originals, which is the contract that makes decorators composable and trustworthy.