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
Python executes exactly this:
def my_function():
pass
my_function = my_decorator(my_function)
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")
Trace the execution:
Python defines
decoratorPython defines
greetPython executes
greet = decorator(greet)— the @ lineInside
decorator,print(f"decorator applied to greet")runs immediatelydecoratorreturnswrapperand binds it to the namegreetPython executes
print("about to call greet")greet("world")is now actuallywrapper("world")wrapperprints "before call", calls the originalgreet, prints "after call"
Output:
decorator applied to greet
about to call greet
before call
hello world
after call
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"))
Stacked decorators apply from bottom to top. This code is equivalent to:
format_text = bold(italic(format_text))
Tracing the call format_text("hello"):
bold's wrapper runs, calls its innerfunc("hello")italic's wrapper runs, calls the originalformat_text("hello")Original
format_textreturns"hello"italic's wrapper wraps it:"<i>hello</i>"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__)
Output:
wrapper
None
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__)
Output:
important_function
This function does something important.
@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()
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)
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
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)
Trace:
add(2, 3)callswrapper(2, 3), incrementscalls[0]to 1, prints "call 1: add(2, 3)", returns 5add(10, 20)callswrapper(10, 20), incrementscalls[0]to 2, prints "call 2: add(10, 20)", returns 30print(5 + 30)prints 35
Output:
call 1: add(2, 3)
call 2: add(10, 20)
35
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)