# The Print vs Return Trap That Silently Breaks Your Python Decorators

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.

```python
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:

```python
# 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

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

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

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

```python
@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.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Making <code>return result</code> your reflex in every wrapper means you never have to think about it.</div>
</div>

* * *

## 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:

```python
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:

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

print(greet("Moussa"))
```

You might expect:

```plaintext
--------------------
HELLO, MOUSSA!
--------------------

```

But what you actually get is:

```plaintext
--------------------
--------------------
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.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong><u>The lesson</u></strong>: Decorators that transform values compose beautifully together. Decorators that use side effects like <code>print()</code> don't, because side effects escape the decorator chain immediately and can't be controlled by other decorators.</div>
</div>

The fix is to make `surround` use return values instead:

```python
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*](https://blog.moussaamzat.dev/understanding-python-decorators-from-scratch)*, and stay tuned for Part 3 on advanced patterns.*
