Skip to main content

Command Palette

Search for a command to run...

The Print vs Return Trap That Silently Breaks Your Python Decorators

I spent hours debugging this so you don't have to.

Published
•9 min read
The Print vs Return Trap That Silently Breaks Your Python Decorators
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back — every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

When I was learning about decorators, I kept running into the same frustrating problem: my decorated function kept returning None. The decorator looked right. The function looked right. But the values just... vanished.

It took me an embarrassingly long time to realized I was confusing print with return. And I don't think I'm alone as this is the single most common bug when learning decorators, especially you're coming from JavaScript where console.log is your best friend.

Let me save you the headache.


The Difference That Changes Everything

Let's get this absolutely clear before we touch decorators.

print() sends text to the screen. That's it. It's a one-way trip. The value goes to the terminal and vanishes from the program's perspective. You can't catch it, store it, or pass it along.

return silently passes a value back to the caller. Nothing appears on the screen. The value stays inside the program where it can be stored, transformed, or used however the caller wants.

def version_a():
    print("hello")     # shows on screen, returns None

def version_b():
    return "hello"     # shows nothing, gives value to caller

These two functions look almost identical, but they behave completely differently:

# Using version_a:
x = version_a()         # "hello" appears on screen
print(x)                # None  <-- the value is GONE
print(type(x))          # <class 'NoneType'>

# Using version_b:
y = version_b()         # nothing appears on screen
print(y)                # hello  <-- the value is HERE
print(y.upper())        # HELLO  <-- we can transform it
print(len(y))           # 5     <-- we can use it

Think of it this way: print() is like shouting something in a room. Everyone hears it, but you can't unsay it or hand it to someone specific. return is like writing something on a note and handing it to the person who asked. They can read it, pass it on, or put it in their pocket for later.


Why This Breaks Decorators

A decorator's wrapper function is a middleman. It sits between the caller and the original function. Whatever the original function returns, the wrapper has to catch and pass along. If it doesn't, the value disappears.

Here is the most common mistake:

Mistake 1: Forgetting to Return

from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        func(*args, **kwargs)   # called, but result THROWN AWAY
        print("Done")
    return wrapper


@log_calls
def add(a, b):
    return a + b

result = add(2, 3)
print(result)
# Calling add
# Done
# None    <--- WHERE IS THE 5?!

The function add(2, 3) computed 5 and returned it to the wrapper. But the wrapper caught it in mid-air and dropped it. The wrapper itself has no return statement so it returns None. The 5 simply ceased to exist.

The fix is two lines:

from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)  # CATCH the value
        print("Done")
        return result                    # PASS IT ALONG
    return wrapper

Mistake 2: Printing Instead of Returning

This one is sneakier because if feels like it's working:

from functools import wraps

def cache(func):
    @wraps(func)
    def wrapper(*args):
        if args in wrapper.memory:
            print(f"Cached: {wrapper.memory[args]}")    # prints to screen!
        else:
            result = func(*args)
            wrapper.memory[args] = result
            print(f"Computed: {result}")    # prints to screen!

    wrapper.memory = {} # Functions are objects 😉
    return wrapper


@cache
def square(n):
    return n * n

answer = square(5)
print(answer)
# Computed: 25
# None          <--- the answer never arrived!

# Even worse:
total = square(5) + square(3)
# TypeError: unsupported operand type(s) for +: 'NoneType' and 'NoneType'

You see 25 on the screen, so it looks like it's working. But the value went to the terminal, not to the caller. The variable answer holds None, not 25. The wrapper printed the result instead of returning it. It shouted the answer across the room instead of handing it over.

Mistake 3: Returning Formatted Strings Instead of Raw Values

Another trap I fell into was returning descriptive strings from the wrapper:

from functools import wraps

def cache(func):
    @wraps(func)
    def wrapper(*args):
        if args in wrapper.memory:
            return f"(instant!) -> {wrapper.memory[args]}"
        result = func(*args)
        wrapper.memory[args] = result
        return f"(computed) -> {result}"
    wrapper.memory = {} # A function is an object
    return wrapper

@cache
def add(a, b):
    return a + b

result = add(2, 3)
print(result)          # (computed) -> 5
print(result + 10)     # TypeError! Can't add string + int

The decorator changed the function's return type from a number to a string. Any code expecting a number back from add will break. A decorator should be invisible, the caller shouldn't be able to tell the function is decorated.


The Correct Pattern

Always capture the return value and always return it:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        # do whatever you want before
        result = func(*args, **kwargs)    # CATCH the value
        # do whatever you want after
        return result                      # PASS IT ALONG
    return wrapper

Two lines. That's the difference between a decorator that works and one that silently eats your data.


"But What If My Function Doesn't Return Anything?"

Great question! Some functions just do things without returning a value:

def say_hello():
    print("Hello!")    # prints, returns None implicitly

If you wrap this function, result will be None. And returning None is perfectly harmless:

@log_calls
def say_hello():
    print("Hello!")    # no return statement

say_hello()
# Calling say_hello
# Hello!
# Done

# result is None, but nobody cares — it works perfectly

This is exactly why you should always return the result, even if you think the function won't return anything:

  • If the function returns something, you've preserved it. ✅

  • If the function returns nothing, you return None, which is harmless. ✅

You're safer either way.

💡
Making return result your reflex in every wrapper means you never have to think about it.

The Delivery Person Analogy

I find this mental model helpful:

Your wrapper function is a delivery person standing at a door. They knock (call the original function), and the person inside either hands them a package (return value) or just waves hello (no return value).

  • If the delivery person always takes whatever is offered and passes it along, the package arrives safely, and the wave is harmless. Everything works.

  • If the delivery person shouts out the package contents (print), the neighborhood hears it, but the actual recipient gets nothing. The package is "delivered" to the air.

  • If the delivery person ignores what's handed to them (no return), the package sits on the doorstep and rots. The recipient never knows it existed.

Always take the package. Always deliver it.


How This Affects Stacking Decorators

The print vs return distinction also explains a confusing behavior when you stack multiple decorators. Consider these two:

from functools import wraps

def shout(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()          # transforms the RETURN VALUE
    return wrapper

def surround(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("-" * 20)                # SIDE EFFECT - escapes immediately
        result = func(*args, **kwargs)
        print("-" * 20)                # SIDE EFFECT - escapes immediately
        return result

shout works with the return values. surround works with side effect (print). Watch what happens when you stack them:

@shout
@surround
def greet(name):
    return f"Hello, {name}!"

print(greet("Moussa"))

You might expect:

--------------------
HELLO, MOUSSA!
--------------------

But what you actually get is:

--------------------
--------------------
HELLO, MOUSSA!

The dashes aren't surrounding anything. They appear back-to-back at the top, and the uppercase greeting appears separately at the bottom.

Here's why. When greet("Moussa") is called, shout's wrapper runs first (outer decorator runs first). It calls surround's wrapper, which prints the first line of dashes (side effect - immediately on screen), calls the original greet which returns "Hello, Moussa!" silently, prints the second line of dashes (another side effect - immediately on screen), and returns the string. Back in shout's wrapper, it receives the string and uppercases it to HELLO, MOUSSA!, then returns it. Finally your print() call in the main code displays it.

The key: between the two lines of dashes, the string was being passed around silently as a return value. No print() call happened for it between the dashes. The only things that printed between "calling surround's wrapper" and "surround's wrapper returning) were the two lines of dashes, back to back.

💡
The lesson: Decorators that transform values compose beautifully together. Decorators that use side effects like print() don't, because side effects escape the decorator chain immediately and can't be controlled by other decorators.

The fix is to make surround use return values instead:

def surround(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return f"{'—' * 20}\n{result}\n{'—' * 20}"
    return wrapper

Now both decorators work with return values, and stacking produces the expected result regardless of order.


The Rules to Live By

  1. print() sends a value to the screen. It's a one-way trip. The value is gone from the program.

  2. return sends a value back to the caller. It stays in the program and can be used further.

  3. In a decorator wrapper, always use result = func(*args, **kwargs) followed by return result. This is the universal safe pattern.

  4. A decorator should be invisible. The caller shouldn't be able to tell the function is decorated. Don't change return types. Don't print inside the wrapper when you should be returning.

  5. Decorators that work with return values stack well. Decorators that use side effects don't. Prefer return values when designing decorators meant for composition.

  6. When in doubt, return. It's always safe.


What's Next?

In the next article, we'll tackle decorators that accept their own arguments, patterns like @repeat(3) and @retry(5). We'll also learn how to stack decorators effectively.

This is Part 2 of my Python Decorators series. Check out Part 1: Understanding Decorators From Scratch for the foundations, and stay tuned for Part 3 on advanced patterns.

Python Decorators From the Ground Up

Part 4 of 5

A 4-part series that builds your understanding of Python decorators from the ground up: no prior knowledge assumed. We start with functions as objects, work through closures and wrappers, tackle the print vs return trap that silently breaks your code, explore decorators with arguments and stacking, and finish with 10 hands-on exercises. Written by a developer who learned the hard way so you don't have to.

Up next

Understanding Python Decorators From Scratch

Functions are objects. Once that clicks, everything else follows.