DEV Community

Cover image for Decorators Are Just Functions. Here Is Proof.
Ameer Abdullah
Ameer Abdullah

Posted on

Decorators Are Just Functions. Here Is Proof.

Decorators Are Just Functions. Here Is Proof.

The @ symbol in Python is syntax sugar. That is the entire secret to understanding decorators. Strip away the syntax sugar and trace exactly what Python does with the @ symbol.

When you write this:

@my_decorator
def my_function():
    pass
Enter fullscreen mode Exit fullscreen mode

Python executes exactly this:

def my_function():
    pass
my_function = my_decorator(my_function)
Enter fullscreen mode Exit fullscreen mode

The decorator is a function that takes a function and returns a function. The @ symbol is Python doing that assignment for you automatically.

Once you internalize this, every decorator pattern becomes traceable and predictable.


Tracing the Execution Order

def decorator(func):
    print(f"decorator applied to {func.__name__}")
    def wrapper(*args, **kwargs):
        print("before call")
        result = func(*args, **kwargs)
        print("after call")
        return result
    return wrapper

@decorator
def greet(name):
    print(f"hello {name}")

print("about to call greet")
greet("world")
Enter fullscreen mode Exit fullscreen mode

Trace the execution:

  1. Python defines decorator

  2. Python defines greet

  3. Python executes greet = decorator(greet) — the @ line

  4. Inside decorator, print(f"decorator applied to greet") runs immediately

  5. decorator returns wrapper and binds it to the name greet

  6. Python executes print("about to call greet")

  7. greet("world") is now actually wrapper("world")

  8. wrapper prints "before call", calls the original greet, prints "after call"

Output:

decorator applied to greet
about to call greet
before call
hello world
after call
Enter fullscreen mode Exit fullscreen mode

The key insight from this trace: the code inside decorator but outside wrapper runs at decoration time, not at call time. The code inside wrapper runs at call time.


Stacked Decorators

def bold(func):
    def wrapper(*args, **kwargs):
        return "<b>" + func(*args, **kwargs) + "</b>"
    return wrapper

def italic(func):
    def wrapper(*args, **kwargs):
        return "<i>" + func(*args, **kwargs) + "</i>"
    return wrapper

@bold
@italic
def format_text(text):
    return text

print(format_text("hello"))
Enter fullscreen mode Exit fullscreen mode

Stacked decorators apply from bottom to top. This code is equivalent to:

format_text = bold(italic(format_text))
Enter fullscreen mode Exit fullscreen mode

Tracing the call format_text("hello"):

  1. bold's wrapper runs, calls its inner func("hello")

  2. italic's wrapper runs, calls the original format_text("hello")

  3. Original format_text returns "hello"

  4. italic's wrapper wraps it: "<i>hello</i>"

  5. bold's wrapper wraps that: "<b><i>hello</i></b>"

Output: <b><i>hello</i></b>


The functools.wraps Problem

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

@my_decorator
def important_function():
    """This function does something important."""
    pass

print(important_function.__name__)
print(important_function.__doc__)
Enter fullscreen mode Exit fullscreen mode

Output:

wrapper
None
Enter fullscreen mode Exit fullscreen mode

The decorator replaced important_function with wrapper. The original function's name and docstring are gone. This breaks debugging, introspection, and documentation tools.

The fix:

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def important_function():
    """This function does something important."""
    pass

print(important_function.__name__)
print(important_function.__doc__)
Enter fullscreen mode Exit fullscreen mode

Output:

important_function
This function does something important.
Enter fullscreen mode Exit fullscreen mode

@wraps(func) copies the original function's metadata onto the wrapper. Always use it in production decorators.


Decorators with Arguments

def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("hello")

say_hello()
Enter fullscreen mode Exit fullscreen mode

This looks like a decorator with an argument but it is actually a function that returns a decorator.

The expansion is:

say_hello = repeat(3)(say_hello)
Enter fullscreen mode Exit fullscreen mode

repeat(3) is called first and returns decorator. Then decorator(say_hello) is called and returns wrapper. The name say_hello is bound to wrapper.

Output:

hello
hello
hello
Enter fullscreen mode Exit fullscreen mode

The Interview Problem

def trace(func):
    calls = [0]
    def wrapper(*args, **kwargs):
        calls[0] += 1
        print(f"call {calls[0]}: {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper

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

result1 = add(2, 3)
result2 = add(10, 20)
print(result1 + result2)
Enter fullscreen mode Exit fullscreen mode

Trace:

  • add(2, 3) calls wrapper(2, 3), increments calls[0] to 1, prints "call 1: add(2, 3)", returns 5

  • add(10, 20) calls wrapper(10, 20), increments calls[0] to 2, prints "call 2: add(10, 20)", returns 30

  • print(5 + 30) prints 35

Output:

call 1: add(2, 3)
call 2: add(10, 20)
35
Enter fullscreen mode Exit fullscreen mode

Note that calls is a list, not an integer. This is deliberate — without nonlocal, a plain integer assignment inside wrapper would create a local variable instead of modifying the enclosing scope value. Using a mutable list avoids the need for nonlocal.

Practice decorator tracing problems at pycodeit.com.


Top comments (0)