Python Decorator Execution Order

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")
Output [DECORATION TIME] Decorating greet [DECORATION TIME] Returning wrapper for greet [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.

Python Pop Quiz
Given this code, what prints first?
@my_decorator
def greet(name): ...
print("hello")
greet("world")
Not quite. The call to 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 runs
Close, but not first. print("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
Correct. The decorator body runs the instant Python hits the @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 runs

Phase 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))
Output [MAIN] Calling add for the first time [CALL TIME] About to call add [CALL TIME] add returned: 8 8 [MAIN] Calling add for the second time [CALL TIME] About to call add [CALL TIME] add returned: 30 30

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()
Output 1. [DECORATION] decorator body runs for 'say_hello' 2. [DECORATION] wrapper defined, returning it --- decoration is done, calling the function --- 3. [CALL] wrapper pre-call logic 4. [ORIGINAL] say_hello body runs 5. [CALL] wrapper post-call logic
Note

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)
Output compute took 0.024831s

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
Location in WrapperBefore func(*args, **kwargs)
RunsBefore the original function
Common UsesLogging, validation, timing start, auth checks
Original call
Location in WrapperThe func(*args, **kwargs) line
RunsThe actual function body
Common UsesThe function's own logic
Post-call
Location in WrapperAfter func(*args, **kwargs)
RunsAfter the original function returns
Common UsesTiming end, result transformation, cleanup
Python Pop Quiz
You want a decorator that validates arguments before the function runs and logs the return value after. Where does each piece of logic go?
Correct. The wrapper has three zones. Code before 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 wrapper
Not quite. Code in the decorator body (outside the wrapper) runs once at decoration time, not on every call. Validation and logging need to execute each time the function is called, which means they belong inside the wrapper — validation before func(), 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()
Not quite. If both are in post-call, validation would run after the function has already executed — too late to prevent bad arguments. Validation must go in pre-call (before 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 body

How 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)
Output Positional args: ('Kandi', 30) Keyword args: {'role': 'admin'} {'name': 'Kandi', 'age': 30, 'role': 'admin'}

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)}")
Output multiply: 20 divide: None
Critical

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"))
Output HELLO, READER
Python Pop Quiz
A wrapper calls func(*args, **kwargs) but does not use return. What does the decorated function return?
Not quite. The original function does run and does produce its return value, but the wrapper discards it. Because the wrapper has no 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 wrapper
Correct. A Python function without a return 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 wrapper
Not quite. A TypeError 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 symptoms

Why 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)
Output (<cell at 0x...: function object at 0x...>,) <function greet at 0x...>

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.

Note

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__}")
Output Name: wrapper Doc: None

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__}")
Output Name: greet Doc: Return a greeting string. Wrapped: greet

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.

Pro Tip

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.

Python Pop Quiz
After applying @wraps(func) to the wrapper, what does greet.__wrapped__ point to?
Not quite. 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
Correct. @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)) # True
Not quite. The decorator function (my_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 itself

What 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}")
Output [PRE-CALL] Entering divide [POST-CALL] divide returned successfully 5.0 --- [PRE-CALL] Entering divide Caught: division by zero

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")
Output divide took 0.000003s (exception raised) 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.

Critical

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.

Python Pop Quiz
A timer decorator needs to record elapsed time even when the function raises. Which construct guarantees the timing code runs?
Not quite. 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 propagates
Correct. The finally 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 wrapper
Not quite. The decorator body runs once at decoration time, not on every call. Timing needs to happen on each call to measure that specific invocation. It must live inside the wrapper, protected by try/finally to handle exceptions.
# Decorator body = runs ONCE (decoration time) # Wrapper body = runs EVERY CALL # Timing must go in the wrapper with try/finally

Decoration 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()}")
Output [IMPORT TIME] Registering task_a [IMPORT TIME] Registering task_b [MAIN] Registry contains: ['task_a', 'task_b'] [MAIN] Calling task_a: result from 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.

Pro Tip

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())
Output Loading config... --- script starts --- {'debug': True}

"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}")
Output Error: 'NoneType' object is not callable

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. The wrapper has three zones: Pre-call logic (before func()), the original function call, and post-call logic (after func() returns). Data like start timestamps must be captured in the correct zone.
  6. Closures connect the two phases: The wrapper is a closure that captures func and any other variables from the decorator's scope. This is how decoration-time data stays accessible at call time.
  7. 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.
  8. Exceptions skip post-call logic: If the original function raises an exception, the wrapper's post-call code is unreachable. Use try/finally when cleanup must run on every call regardless of outcome.
  9. Always return from the wrapper: A missing return silently discards the original function's return value, replacing it with None.
  10. Always return the wrapper from the decorator: A missing return wrapper at the end of the decorator body makes the decorated name None, causing a TypeError on 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.

  1. Write the decorator body with a decoration-time print. Define a function that accepts func as 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.
  2. Define the wrapper with pre-call and post-call prints. Inside the decorator body, define a wrapper function that accepts *args and **kwargs. Add a print statement before calling func(*args, **kwargs) for pre-call tracing and another print statement after capturing the result for post-call tracing. Return the result.
  3. Apply @wraps(func) to the wrapper. Import wraps from functools and 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.
  4. Return the wrapper from the decorator. At the end of the decorator body, add return wrapper. Without this line, the decorator returns None and the decorated function becomes uncallable.
  5. Apply the decorator and call the function. Place @your_decorator above 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.