The Print vs Return Trap That Silently Breaks Your Python Decorators
I spent hours debugging this so you don't have to.

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.
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.
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
print()sends a value to the screen. It's a one-way trip. The value is gone from the program.returnsends a value back to the caller. It stays in the program and can be used further.In a decorator wrapper, always use
result = func(*args, **kwargs)followed byreturn result. This is the universal safe pattern.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.
Decorators that work with return values stack well. Decorators that use side effects don't. Prefer return values when designing decorators meant for composition.
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.






