A Python decorator is a function that takes another function, wraps it, and returns the wrapped version. That sentence is simple enough, but it hides a critical detail: a decorator executes in two separate phases, and the code that runs in each phase is different. Confusing these phases is the source of nearly every decorator bug that beginners encounter. This article walks through the complete execution lifecycle of a single decorator — what runs when the module loads, what runs when the function is called, and how data flows between the two phases — with traced output at every step.
What the @ Syntax Desugars To
The @ symbol is syntactic sugar. When Python sees this:
@my_decorator
def greet(name):
return f"Hello, {name}"
It translates it into exactly this:
def greet(name):
return f"Hello, {name}"
greet = my_decorator(greet)
Python defines the function greet first, then immediately passes it to my_decorator. Whatever my_decorator returns is bound back to the name greet. From that point on, calling greet() calls whatever object my_decorator returned — usually an inner wrapper function.
This desugaring reveals the two phases. The call to my_decorator(greet) is phase 1 (decoration time). Every subsequent call to greet("reader") is phase 2 (call time).
Phase 1: Decoration Time
Decoration time is when Python calls the decorator with the function as its argument. This happens once, at the moment the @ line is reached. Any code inside the decorator body but outside the inner wrapper runs during this phase:
def my_decorator(func):
print(f"[DECORATION TIME] Decorating {func.__name__}")
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
print(f"[DECORATION TIME] Returning wrapper for {func.__name__}")
return wrapper
@my_decorator
def greet(name):
return f"Hello, {name}"
print("[MAIN] Script continues after decoration")
Both print statements inside my_decorator run before the script even reaches the main body. The function greet has not been called yet — it has only been decorated. The wrapper function is defined and returned, but it has not been executed.
@my_decoratordef greet(name): ...print("hello")greet("world")greet("world") is the last line, so its output comes last. The decorator body runs first — the moment Python reaches the @my_decorator line, before either print("hello") or greet("world") execute.# Execution order:
# 1. @my_decorator line -> decorator body runs
# 2. print("hello") -> "hello"
# 3. greet("world") -> wrapper runsprint("hello") runs second. The decorator body runs first because Python executes the @my_decorator line before it continues to the next statement in the module. Decoration time always comes before the script's main body.# Execution order:
# 1. @my_decorator line -> decorator body runs
# 2. print("hello") -> "hello"
# 3. greet("world") -> wrapper runs@my_decorator line. That happens before the script continues to print("hello") and long before greet("world") is called. Decoration time always fires first.# Execution order:
# 1. @my_decorator line -> decorator body runs
# 2. print("hello") -> "hello"
# 3. greet("world") -> wrapper runsPhase 2: Call Time
Call time is every subsequent call to the decorated function. The code inside the wrapper runs during this phase:
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"[CALL TIME] About to call {func.__name__}")
result = func(*args, **kwargs)
print(f"[CALL TIME] {func.__name__} returned: {result}")
return result
return wrapper
@my_decorator
def add(a, b):
return a + b
print("[MAIN] Calling add for the first time")
print(add(3, 5))
print("[MAIN] Calling add for the second time")
print(add(10, 20))
The wrapper runs every single time add() is called. Decoration happened silently when the @my_decorator line was reached. The wrapper code is the repeated behavior.
Tracing Both Phases with Print Statements
Putting print statements in both phases at once makes the full lifecycle visible in a single script:
def trace(func):
print(f"1. [DECORATION] decorator body runs for '{func.__name__}'")
def wrapper(*args, **kwargs):
print(f"3. [CALL] wrapper pre-call logic")
result = func(*args, **kwargs)
print(f"5. [CALL] wrapper post-call logic")
return result
print(f"2. [DECORATION] wrapper defined, returning it")
return wrapper
@trace
def say_hello():
print(f"4. [ORIGINAL] say_hello body runs")
print("--- decoration is done, calling the function ---")
say_hello()
The numbered output shows the exact sequence. Steps 1 and 2 happen once at decoration time. Steps 3, 4, and 5 happen every time the function is called. Step 4 is the original function itself, sandwiched between the wrapper's pre-call (3) and post-call (5) logic.
Pre-Call and Post-Call Logic
The wrapper function has three distinct zones. Code before func(*args, **kwargs) is pre-call logic. The func() call itself invokes the original function. Code after it is post-call logic:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
# PRE-CALL: record start time
start = time.perf_counter()
# ORIGINAL FUNCTION
result = func(*args, **kwargs)
# POST-CALL: calculate elapsed time
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timer
def compute(n):
return sum(range(n))
compute(1_000_000)
The timer starts before the function runs (pre-call), the function executes, and the elapsed time is calculated after the function returns (post-call). This three-zone structure is the core pattern of every decorator.
Pre-call
func(*args, **kwargs)Original call
func(*args, **kwargs) linePost-call
func(*args, **kwargs)func(*args, **kwargs) runs pre-call, and code after it runs post-call. Validation needs to happen before the function executes, and logging the return value requires the result, which only exists after the call.from functools import wraps
def validate_and_log(func):
@wraps(func)
def wrapper(*args, **kwargs):
# PRE-CALL: validate arguments
if not args:
raise ValueError("At least one argument required")
result = func(*args, **kwargs)
# POST-CALL: log the return value
print(f"{func.__name__} returned {result!r}")
return result
return wrapperfunc(), logging after.# Decorator body = runs ONCE at decoration time
# Wrapper pre-call = runs EVERY call, before func()
# Wrapper post-call = runs EVERY call, after func()func()) so the function never runs with invalid input.# Validation = PRE-CALL (before func runs)
# Logging = POST-CALL (after func returns)
# Both live inside the wrapper, not the decorator bodyHow Arguments Flow Through the Wrapper
The wrapper uses *args and **kwargs to accept any arguments the caller passes. It forwards them to the original function unchanged:
from functools import wraps
def log_args(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Positional args: {args}")
print(f"Keyword args: {kwargs}")
return func(*args, **kwargs)
return wrapper
@log_args
def create_user(name, age, role="viewer"):
return {"name": name, "age": age, "role": role}
result = create_user("Kandi", 30, role="admin")
print(result)
The caller passes "Kandi" and 30 as positional arguments and role="admin" as a keyword argument. The wrapper captures them, logs them, and passes them through to the original function without modification. The decorator can also modify arguments before forwarding them, but the default pattern is transparent passthrough.
How Return Values Flow Back
The wrapper must explicitly return the result of func(*args, **kwargs). Without the return statement, the decorated function returns None regardless of what the original function produces:
def correct_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result # passes the return value back to the caller
return wrapper
def broken_decorator(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs) # no return!
return wrapper
@correct_decorator
def multiply(a, b):
return a * b
@broken_decorator
def divide(a, b):
return a / b
print(f"multiply: {multiply(4, 5)}")
print(f"divide: {divide(10, 2)}")
A missing return in the wrapper is one of the most common decorator bugs. The original function runs and produces a value, but the wrapper discards it. Always capture the result in a variable and return it explicitly.
The wrapper can also transform the return value in post-call logic before returning it to the caller:
from functools import wraps
def uppercase_result(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if isinstance(result, str):
return result.upper()
return result
return wrapper
@uppercase_result
def greet(name):
return f"hello, {name}"
print(greet("reader"))
func(*args, **kwargs) but does not use return. What does the decorated function return?return statement, Python implicitly returns None from the wrapper. The caller gets None instead of the real result.def broken(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs) # runs, but result is lost
# no return statement -> wrapper returns None
return wrapperreturn statement implicitly returns None. The original function runs and produces its value, but the wrapper never passes it back. The fix is result = func(*args, **kwargs) followed by return result.def fixed(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result # now the caller gets the real value
return wrapperTypeError happens when the decorator forgets to return wrapper (making the decorated name None). In this case, the wrapper exists and runs fine — it just silently discards the return value, giving the caller None instead.# Missing return in wrapper -> returns None
# Missing return wrapper in decorator -> TypeError
# Different bugs, different symptomsWhy the Wrapper Remembers the Original Function
A question that comes up naturally once the two-phase model is clear: after the decorator finishes and returns the wrapper, how does the wrapper still have access to func? The decorator's local scope should be gone. The answer is that the wrapper is a closure.
A closure is a function that captures variables from its enclosing scope and retains access to them even after the enclosing function has returned. When Python defines the wrapper inside the decorator body, it binds a reference to func into the wrapper's closure. That reference persists for the lifetime of the wrapper object:
def my_decorator(func):
# func is a local variable in my_decorator's scope
def wrapper(*args, **kwargs):
# wrapper captures func from the enclosing scope
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
return f"Hello, {name}"
# The decorator has already returned, but wrapper
# still holds a reference to the original greet
print(greet.__closure__)
print(greet.__closure__[0].cell_contents)
The __closure__ attribute reveals the cell objects that the wrapper holds. Each cell contains a reference to a variable from the enclosing scope. In this case, the first cell holds the original greet function. This is why the wrapper can call func(*args, **kwargs) on every invocation — it never loses access to the original function, even though the decorator itself finished executing during decoration time.
Any variable defined in the decorator body and referenced inside the wrapper becomes part of the closure. This is how decorators share data between decoration time and call time — the decorator sets up the data, the wrapper reads it on every call through the closure.
Preserving Function Identity with functools.wraps
Once a decorator replaces a function with a wrapper, the decorated name no longer points to the original function object. That means the function's __name__, __doc__, and __module__ attributes now belong to the wrapper, not the original. This causes problems with debugging, logging, and any tool that inspects function metadata:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}"
print(f"Name: {greet.__name__}")
print(f"Doc: {greet.__doc__}")
The function reports its name as wrapper instead of greet, and its docstring is gone. The fix is functools.wraps, which copies the original function's metadata onto the wrapper at decoration time:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}"
print(f"Name: {greet.__name__}")
print(f"Doc: {greet.__doc__}")
print(f"Wrapped: {greet.__wrapped__.__name__}")
With @wraps(func) applied to the wrapper, the decorated function correctly reports its original name and docstring. The @wraps decorator also sets a __wrapped__ attribute on the wrapper that points to the original function, which tools like inspect.unwrap() use to access the undecorated version. This is a decoration-time operation — @wraps(func) runs once when the wrapper is defined, not on every call.
Always apply @wraps(func) to the wrapper function in every decorator you write. It is a single line that prevents an entire category of metadata bugs. Without it, stack traces show wrapper instead of the real function name, help() returns no docstring, and serialization tools like pickle fail because they cannot resolve the function by name.
@wraps(func) to the wrapper, what does greet.__wrapped__ point to?greet already is the wrapper after decoration. The __wrapped__ attribute provides the escape hatch back to the original function, which is exactly what inspect.unwrap() uses to peel back decorator layers.import inspect
# greet is the wrapper, but __wrapped__ leads back
original = inspect.unwrap(greet)
print(original is greet.__wrapped__) # True@wraps(func) sets wrapper.__wrapped__ = func, pointing directly to the original undecorated function. This is what inspect.unwrap() follows to strip away decorator layers, and it makes testing the original function straightforward.import inspect
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
return f"Hello, {name}"
# Access the original function directly
original = greet.__wrapped__
print(original("reader")) # Hello, reader
print(original is inspect.unwrap(greet)) # Truemy_decorator) is not stored anywhere on the wrapper. __wrapped__ points to the original greet function that was passed as func — the function the decorator received and wrapped.# __wrapped__ = the original function (func)
# NOT the decorator (my_decorator)
# NOT the wrapper itselfWhat Happens When the Original Function Raises an Exception
The three-zone wrapper pattern assumes the original function returns normally. When the function raises an exception instead, execution jumps out of the wrapper immediately. Any post-call logic below the func(*args, **kwargs) line is skipped entirely:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[PRE-CALL] Entering {func.__name__}")
result = func(*args, **kwargs)
print(f"[POST-CALL] {func.__name__} returned successfully")
return result
return wrapper
@log_calls
def divide(a, b):
return a / b
print(divide(10, 2))
print("---")
try:
divide(10, 0)
except ZeroDivisionError as e:
print(f"Caught: {e}")
On the second call, the pre-call message prints, but the post-call message never appears. The ZeroDivisionError propagates out of func(*args, **kwargs), past the rest of the wrapper, and up to the caller. The wrapper's post-call logic is unreachable for that call.
If the decorator needs to guarantee that cleanup code runs regardless of whether the function succeeds or fails, the wrapper must use try/finally:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
raised = True
try:
result = func(*args, **kwargs)
raised = False
return result
finally:
elapsed = time.perf_counter() - start
status = " (exception raised)" if raised else ""
print(f"{func.__name__} took {elapsed:.6f}s{status}")
return wrapper
@timer
def divide(a, b):
return a / b
try:
divide(10, 0)
except ZeroDivisionError:
print("Error caught by caller")
The finally block runs whether the function returns normally or raises an exception. The timer still records the elapsed time, and the exception still propagates to the caller. This pattern is essential for any decorator that manages resources, records metrics, or performs cleanup that must happen on every call regardless of outcome.
Do not silently swallow exceptions in a decorator wrapper with a bare except clause. The caller expects exceptions from the original function to propagate normally. A decorator that catches and suppresses exceptions changes the function's contract in a way the caller cannot anticipate. Use try/finally for cleanup, or try/except only if the decorator's explicit purpose is error handling and the behavior is documented.
try/except Exception would catch the exception and prevent it from reaching the caller. That changes the function's contract. The caller expects a ZeroDivisionError and never gets it. Use try/finally instead — the finally block runs without catching or suppressing the exception.# try/except = catches and suppresses the error
# try/finally = runs cleanup, exception still propagatesfinally block runs regardless of whether the function returns or raises, and it does not suppress the exception. The error still propagates to the caller exactly as expected, while the timing code runs unconditionally.from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return wrappertry/finally to handle exceptions.# Decorator body = runs ONCE (decoration time)
# Wrapper body = runs EVERY CALL
# Timing must go in the wrapper with try/finallyDecoration Time Runs at Import
For functions defined at the top level of a module, decoration time coincides with import time. This means the decorator body runs the moment the module is imported, before any of the module's functions are called:
# registration_demo.py
registry = []
def register(func):
print(f"[IMPORT TIME] Registering {func.__name__}")
registry.append(func.__name__)
return func
@register
def task_a():
return "result from task_a"
@register
def task_b():
return "result from task_b"
print(f"[MAIN] Registry contains: {registry}")
print(f"[MAIN] Calling task_a: {task_a()}")
The register decorator runs during import and populates the registry before any function is called. This pattern is used by frameworks like Flask (@app.route) and pytest (@pytest.fixture) to discover and register handlers at import time.
Notice that register returns func unchanged — it does not wrap it. Not every decorator needs to return a wrapper. Decorators that only need to perform one-time registration, validation, or side effects at decoration time can return the original function untouched.
Common Mistakes from Confusing the Two Phases
Putting Call-Time Logic in the Decorator Body
import time
from functools import wraps
# WRONG: timestamp captured once at decoration time
def bad_timer(func):
start = time.perf_counter() # runs once when module loads!
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"Elapsed: {time.perf_counter() - start:.2f}s")
return result
return wrapper
# CORRECT: timestamp captured every call
def good_timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter() # runs every call
result = func(*args, **kwargs)
print(f"Elapsed: {time.perf_counter() - start:.6f}s")
return result
return wrapper
In bad_timer, start is set once at decoration time and never changes. Every call measures elapsed time from import, not from the call. Moving start inside the wrapper fixes it because the wrapper runs fresh on every call.
Calling the Function at Decoration Time
# WRONG: calls the function immediately during decoration
def bad_decorator(func):
result = func() # called at decoration time!
def wrapper(*args, **kwargs):
return result
return wrapper
# The function runs at import, before anyone calls it
@bad_decorator
def get_config():
print("Loading config...")
return {"debug": True}
print("--- script starts ---")
print(get_config())
"Loading config..." prints before "script starts" because the decorator calls func() in its body, which runs at decoration time. The function should only be called inside the wrapper, not in the decorator body.
Forgetting to Return the Wrapper
def broken(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# missing: return wrapper
@broken
def add(a, b):
return a + b
try:
add(1, 2)
except TypeError as e:
print(f"Error: {e}")
Without return wrapper, the decorator returns None. The name add is then bound to None, and calling add(1, 2) tries to call None.
Key Takeaways
- Decorators have two phases: Decoration time (runs once when the
@line is reached) and call time (runs every time the decorated function is called). Knowing which code belongs in which phase is fundamental. - The @ syntax desugars to
func = decorator(func): Python calls the decorator immediately after defining the function and rebinds the name to whatever the decorator returns. - Decorator body code runs at decoration time: Any code inside the decorator but outside the wrapper runs once. For top-level functions, this is import time. Use this for registration, configuration, or validation.
- Wrapper code runs at call time: Any code inside the wrapper runs on every function call. Use this for logging, timing, access control, and result transformation.
- The wrapper has three zones: Pre-call logic (before
func()), the original function call, and post-call logic (afterfunc()returns). Data like start timestamps must be captured in the correct zone. - Closures connect the two phases: The wrapper is a closure that captures
funcand any other variables from the decorator's scope. This is how decoration-time data stays accessible at call time. - Always use
@wraps(func): Without it, the decorated function loses its original__name__,__doc__, and__module__. This single line prevents metadata bugs in debugging, logging, and serialization. - Exceptions skip post-call logic: If the original function raises an exception, the wrapper's post-call code is unreachable. Use
try/finallywhen cleanup must run on every call regardless of outcome. - Always return from the wrapper: A missing
returnsilently discards the original function's return value, replacing it withNone. - Always return the wrapper from the decorator: A missing
return wrapperat the end of the decorator body makes the decorated nameNone, causing aTypeErroron the first call.
Understanding the two-phase lifecycle is the single concept that makes every other decorator pattern click. Decoration time sets things up; call time executes the behavior. Every decorator you read or write follows this structure.
How to Trace the Execution Order of a Python Decorator
The following steps walk through writing a decorator with print statements in both phases, confirming the decoration-time vs call-time lifecycle with traced output.
- Write the decorator body with a decoration-time print. Define a function that accepts
funcas its parameter. Inside the body but outside any inner function, add a print statement that logs the function name. This code runs once when Python reaches the@line. - Define the wrapper with pre-call and post-call prints. Inside the decorator body, define a wrapper function that accepts
*argsand**kwargs. Add a print statement before callingfunc(*args, **kwargs)for pre-call tracing and another print statement after capturing the result for post-call tracing. Return the result. - Apply
@wraps(func)to the wrapper. Importwrapsfromfunctoolsand apply@wraps(func)directly above the wrapper definition. This copies the original function's__name__,__doc__, and other metadata onto the wrapper at decoration time. - Return the wrapper from the decorator. At the end of the decorator body, add
return wrapper. Without this line, the decorator returnsNoneand the decorated function becomes uncallable. - Apply the decorator and call the function. Place
@your_decoratorabove a function definition, then call the function. The decoration-time prints appear immediately when the@line is reached. The wrapper prints appear each time the function is called, confirming the two-phase lifecycle.
Frequently Asked Questions
When does a Python decorator execute?
A decorator executes in two phases. The decorator body runs once at decoration time, which is when the module is imported or when Python reaches the @decorator line during script execution. The wrapper function runs every time the decorated function is called. Code inside the decorator but outside the wrapper runs once; code inside the wrapper runs on every call.
What does the @ syntax desugar to in Python?
The line @decorator above a function definition is equivalent to writing func = decorator(func) immediately after the function definition. Python calls the decorator with the function as its argument and binds the result back to the original function name in the namespace.
Does code inside a decorator body run at import time?
Yes. Code inside the decorator function but outside the inner wrapper runs once when the decorator is applied. This happens at module import time for top-level decorated functions, or at class definition time for decorated methods. The wrapper function itself only runs when the decorated function is called.
What is the difference between pre-call and post-call logic in a decorator?
Pre-call logic is any code in the wrapper that runs before calling the original function (before the func(*args, **kwargs) line). Post-call logic runs after the original function returns. This structure lets the decorator execute code before the function, after the function, or both, and even modify the arguments or return value.
Why does my decorated function return None instead of the expected value?
A decorated function returns None when the wrapper function does not explicitly return the result of calling the original function. The wrapper must capture the return value with result = func(*args, **kwargs) and then return result. Without this return statement, Python implicitly returns None from the wrapper, silently discarding the original function's output.
What happens if a decorator does not return the wrapper function?
If the decorator body does not include a return wrapper statement, the decorator implicitly returns None. Python then rebinds the decorated function name to None. The first time the decorated function is called, it raises a TypeError because None is not callable. Always end the decorator body with return wrapper to avoid this error.
How does the wrapper function access the original function after the decorator returns?
The wrapper is a closure. When Python defines the wrapper inside the decorator body, it captures a reference to func from the enclosing scope. That reference is stored in the wrapper's __closure__ attribute and persists for the lifetime of the wrapper object, even after the decorator function itself has finished executing.
What does functools.wraps do in a decorator?
The @wraps(func) decorator copies the original function's __name__, __doc__, __module__, and other metadata attributes onto the wrapper function at decoration time. Without it, the decorated function reports the wrapper's name and has no docstring, which breaks debugging, logging, help(), and serialization tools like pickle.
Does post-call logic in a decorator run if the original function raises an exception?
No. If the original function raises an exception, execution jumps out of the wrapper immediately. Any post-call code below the func(*args, **kwargs) line is skipped. To guarantee that cleanup or measurement code runs regardless of whether the function succeeds or fails, use a try/finally block inside the wrapper.