Skip to content
advanced

Python Decorators Under the Hood: Wrappers, Closures, and Function Transformation

A decorator is not a wrapper. It is a rebinding that happens once, at import time — and most decorator bugs live in the gap between that fact and the…

Published 2026-09-11Updated 2026-09-1215 min read
A vibrant red apple with glistening water droplets on a dark backdrop, emphasizing freshness and natural appeal.
A vibrant red apple with glistening water droplets on a dark backdrop, emphasizing freshness and natural appeal. Photo by Ashutosh Sonwani on Pexels.

A decorator is not a wrapper. It is a rebinding that happens once, at import time — and most decorator bugs live in the gap between that fact and the wrapper you thought you wrote.

Here is the failure I see most often. A function works perfectly in isolation. Then it gets decorated, and something downstream breaks: a framework reads the wrong signature, a debugger shows wrapper instead of the real name, a doc generator prints an empty docstring, or a test patches the original name and silently patches nothing. The code still runs. The behavior is still mostly right. But the object the rest of the system sees is no longer the function you defined.

Most engineers learn decorators as "a function that takes a function and returns a new one." That definition is true and nearly useless for debugging, because it hides the two facts that determine everything: decoration happens once at definition time, and the name you call afterward is bound to a different object than the one you wrote. Once you internalize those, decorators stop being syntax to memorize and become a transformation you can trace.

For function-wrapping decorators, the failure surfaces cluster into four mechanics: the wrapper's call signature, the closure it captures, the metadata it fails to copy, and the moment it runs. That four-part lens covers the common case. It does not cover everything — registration decorators whose real work is a side effect, and class-based decorators that change the replacement object's binding behavior, sit outside it. I will flag both when we reach them.

Decoration Is a Rebinding, Not a Call

A flow from the original function to the decorator at import time, then to a rebound name pointing to the wrapper; a separate call-time path shows arguments entering the wrapper, reaching the original function, and returning the result.
Decoration happens once: the name is rebound to the decorator’s result, and later calls pass through that wrapper.

Start with the desugaring, because everything else depends on it:

@d
def f():
    ...

is exactly:

def f():
    ...
f = d(f)

That is the whole trick. The original function object is created, then immediately passed to d, and the name f is rebound to whatever d returns. Nothing about the original function object changes. The name simply points somewhere new.

The critical distinction is when each piece runs. The decorator body — the code inside d — runs once, when the module is imported or the class body executes. The wrapper body runs on every call. Most decorator confusion is a category error between these two moments.

Decoration is import-time. Invocation is call-time. A decorator cannot see runtime arguments at decoration time, and it cannot be reconfigured after import.

That second consequence is why "I want to change the decorator's behavior later" is a design smell, not a bug. By the time you have a reference to the decorated function, the decoration already happened. If you need runtime configuration, the configuration has to live in a mutable object the wrapper reads on each call — not in the decorator's arguments.

Stacking follows directly from the desugaring. This:

@a
@b
def f():
    ...

means f = a(b(f)). So a is the outermost layer: it runs first on the way in and last on the way out. Trace one stacked example by hand before trusting your intuition — the order surprises people more often than they expect.

Decorating a method is the same mechanism with one extra fact: the wrapper receives self as its first positional argument, exactly like any other argument. This is why *args, **kwargs in a wrapper is not laziness. It is a requirement.

Here is the smallest useful decorator, deliberately broken so the next sections have something to repair:

def log_calls(func):
    def wrapper():
        print(f"calling {func.__name__}")
        return func()
    return wrapper

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

add(2, 3)

This raises TypeError: wrapper() takes 0 positional arguments but 2 were given. Note where the failure appears: at call time, not at decoration time. The decorator accepted add without complaint. The breakage is delayed and misattributed — which is exactly why decorator bugs feel like magic.

Knowledge check

Check your understanding

Answer this question before you continue.

A module defines `@d def f(): ...` and then imports. Which sequence accurately describes what happens?
Scenario Interpretation

Focus: Trace decorator desugaring and distinguish decoration-time execution from call-time execution.

The Wrapper's Signature Is a Contract You Can Break

The wrapper replaces the original in the namespace, so its parameter list is the interface callers and tools actually hit. When the wrapper does not faithfully forward arguments, it silently changes the decorated function's contract.

*args, **kwargs fixes the forwarding problem:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

But it does not preserve the signature. Anything that inspects the signature — frameworks doing dependency injection, CLI builders, test runners, inspect.signature — now sees (*args, **kwargs) instead of (a, b). The wrapper is transparent to calls and opaque to inspection. Those are different properties, and conflating them is where framework integrations break.

functools.wraps is the metadata fix, and it is worth being precise about what it does. It copies __name__, __doc__, __module__, __qualname__, and __dict__ from the wrapped function onto the wrapper, and it sets __wrapped__ to point back at the original. It does not change the wrapper's actual parameter list. The wrapper still accepts *args, **kwargs.

So why does functools.wraps often appear to fix signature inspection? Because inspect.signature follows __wrapped__ by default. When it finds that attribute, it reports the original function's signature instead of the wrapper's. That is a convention tools honor, not a language guarantee. Code that reads wrapper.__code__.co_varnames directly, or that ignores __wrapped__, will still see the wrapper.

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Compare the symptoms before and after:

SymptomWithout wrapsWith wraps
f.__name__"wrapper""add"
f.__doc__Noneoriginal docstring
inspect.signature(f)(*args, **kwargs)(a, b)
Traceback framespoint into decoratorpoint at real function
f.__wrapped__missingoriginal function

My rule: apply functools.wraps unless you have a specific reason not to. And when a framework inspects signatures, verify the behavior rather than assuming the wrapper is transparent — the table above is what you check, not what you hope.

Knowledge check

Check your understanding

Answer this question before you continue.

A wrapper accepts `*args, **kwargs` and forwards them correctly, but a framework using `inspect.signature` still needs to see the original `(a, b)` signature. Which change addresses that inspection behavior?
Comparison Reasoning

Focus: Distinguish argument forwarding from metadata-based signature preservation in a wrapper.

Closures Are Where the Decorator Keeps Its State

The wrapper works because it is a closure. It references func from the enclosing decorator scope, so func stays alive after the decorator returns. Without that capture, the wrapper would have nothing to call.

Closure capture and nonlocal are their own topic, so I will not re-teach them here. The point that matters for decorators is narrower: the decorator's enclosing scope is the decorator call, and it is evaluated once. That single fact produces both the parameterized-decorator pattern and its most common bug.

Parameterized decorators are a three-level closure. This:

@retry(times=3)
def fetch():
    ...

means fetch = retry(times=3)(fetch). The outer function takes configuration, the middle takes the function, the inner is the wrapper. Trace the sequence explicitly:

  1. retry(times=3) runs at import time and returns a decorator.
  2. That decorator is applied to fetch, returning the wrapper.
  3. fetch is rebound to the wrapper.
  4. The wrapper closes over both func and times.

This is where most people lose the thread, because there are now two "decorator" functions and only one of them is the one they picture.

The late-binding trap is the classic failure. A loop that creates several wrappers capturing a loop variable will have all of them see the final value:

def make_logger(prefix):
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(f"{prefix}: {func.__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

loggers = [make_logger(f"L{i}") for i in range(3)]

If prefix were captured by reference to a loop variable rather than bound as a parameter, every logger would print the last value. The fix is to bind the value at creation time — as a default argument or, as above, as a function parameter that closes over a fresh scope per call.

State lifetime matters too. State stored in the decorator's closure is shared across every call of every function that decorator instance wraps. If you need per-call state, it belongs in the wrapper's local scope. If you need per-function state, it belongs on the wrapper object.

Closure state mutated by the wrapper is shared mutable state. Under threads or async tasks, an unsynchronized counter or cache in a decorator is a race, not a feature.

That last point is not theoretical. A memoization decorator with a plain dict and no lock is fine under a single-threaded test suite and quietly wrong under a thread pool.

Knowledge check

Check your understanding

Answer this question before you continue.

A decorator closure contains a counter that the wrapper increments on every invocation. What is the default lifetime of that counter for one decorated function?
Scenario Interpretation

Focus: Determine whether decorator state is shared across calls and identify the appropriate scope for per-call state.

Class-Based Decorators and the State You Actually Need

A class-based decorator works because __call__ makes an instance callable. @Cache on a function means the function is replaced by an instance whose __call__ forwards to it:

class Cache:
    def __init__(self, func):
        self.func = func
        self.hits = 0
        self.store = {}

    def __call__(self, *args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key not in self.store:
            self.store[key] = self.func(*args, **kwargs)
        else:
            self.hits += 1
        return self.store[key]

The real advantage is explicit, named state. hits, store, and any reset method live as attributes instead of as closure variables you cannot reach. That reachability is the design win — not the syntax. When you need to inspect a cache's hit rate or clear it between tests, an attribute beats a closure every time.

The cost is that an instance is not a function. functools.wraps applied to __call__ helps with metadata, but inspect.isfunction returns False, and code that assumes a plain function will behave differently.

There is a second cost that bites harder, and it is the boundary the four-mechanics lens does not cover. When a class-based decorator wraps a method, the replacement object is an instance, and a plain instance is not a descriptor. Ordinary functions are descriptors: they implement __get__, which is what binds self when you access a method through an instance. A callable instance has no __get__, so self never gets injected. Watch it happen:

class Cache:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)

class Service:
    @Cache
    def handle(self, request):
        return f"handled {request}"

Service().handle("r1")

This raises TypeError: handle() missing 1 required positional argument: 'request'. The call Service().handle("r1") passes "r1" as the first argument, but nothing supplied self. The instance was returned by attribute lookup without binding, so self was never filled in.

The repair is to give the replacement object descriptor behavior by implementing __get__:

import functools

class Cache:
    def __init__(self, func):
        self.func = func
        self.store = {}

    def __call__(self, *args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key not in self.store:
            self.store[key] = self.func(*args, **kwargs)
        return self.store[key]

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return functools.partial(self.__call__, obj)

Now Service().handle("r1") binds obj as self and forwards it. Notice what just happened: you stopped solving a decorator problem and started solving a descriptor problem. That is a different layer with different rules, and it is the honest reason to prefer a function-based wrapper for methods unless you specifically need the instance's inspectable state.

The decision boundary I use:

  • Function-based decorator for stateless or simple wrapping, and for methods where you do not need instance state.
  • Class-based decorator when you need inspectable state, multiple configuration knobs, or a reset/inspect API — and you are willing to handle __get__ if it wraps methods.
  • Descriptor or metaclass when you need per-attribute or per-class behavior, because a decorator is the wrong layer for that.

And when not to use a decorator at all: if the behavior needs to be conditional at call time, composed dynamically, or if it obscures control flow in a hot path, an explicit wrapper call or a context manager is often clearer and easier to debug. A decorator is a definition-time decision. If your decision is a call-time decision, do not force it into a decorator.

Knowledge check

Check your understanding

Answer this question before you continue.

A class-based decorator replaces a method with a callable instance. `Service().handle("r1")` raises `TypeError` because the original method's `self` was not injected. What repair matches the article's mechanism?
Debugging

Focus: Diagnose why a class-based decorator breaks method binding and select the descriptor-based repair.

Debugging a Decorator Without Guessing

Turn the mechanism into a procedure. When a decorated function misbehaves, work these five steps in order.

Step one: identify the moment. Does the failure happen at import (decorator body, configuration, registration) or at call (wrapper body, argument forwarding, return value)? The traceback line usually answers this immediately. An error inside the decorator body means import time. An error inside the wrapper means call time.

Step two: inspect the object. Print the four attributes that reveal metadata state:

print(f.__name__)
print(f.__doc__)
print(getattr(f, "__wrapped__", None))
print(inspect.signature(f))

If __name__ is "wrapper" or __wrapped__ is missing, metadata is your bug. Fix that before chasing anything else.

Step three: check the boundary. Call the wrapper with the exact argument shapes the real caller uses — positional, keyword, defaults, self. A wrapper that only ever gets tested with *args hides signature bugs until production.

Step four: check the state. If behavior drifts across calls, look for closure or instance state that is shared when you assumed it was fresh, or fresh when you assumed it was shared.

Step five: check the stack. A traceback that points into the decorator instead of the decorated function is a metadata symptom, not a logic symptom.

Here is the compact worked example. A timing decorator that looks correct:

import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

It works until a framework inspects the signature to build a schema. inspect.signature(timed_func) returns (*args, **kwargs), the framework cannot map parameters, and the integration fails. The repair is one line:

import functools

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        ...
    return wrapper

Now inspect.signature follows __wrapped__ and reports the original signature. The wrapper's parameter list never changed — only the metadata tools read did.

Metadata correctness is necessary but not sufficient. Two checks catch the boundaries the metadata drill misses. First, ordering: for stacked decorators, log entry and exit and confirm the outermost runs first in and last out.

def a(func):
    def wrapper(*args, **kwargs):
        print("a in")
        result = func(*args, **kwargs)
        print("a out")
        return result
    return wrapper

def b(func):
    def wrapper(*args, **kwargs):
        print("b in")
        result = func(*args, **kwargs)
        print("b out")
        return result
    return wrapper

@a
@b
def f():
    print("f")

f()

The output is a in, b in, f, b out, a out — the desugaring made visible. Second, awaitability: a decorator that wraps an async def with a plain synchronous wrapper returns a coroutine object that is never awaited, so the function silently does nothing. If the decorated function is a coroutine, the wrapper must be async def and await func(...), or you must return the coroutine deliberately. Assert inspect.iscoroutinefunction(decorated) when that contract matters.

Where Decorators Earn Their Place in Real Systems

The mechanism matters because decorators sit at the seams of real systems, and the seams are where the constraints live.

Instrumentation and tracing. Decorators are the natural place to attach timing, logging, and span creation. But the wrapper adds a stack frame and a call per invocation. In hot paths, measure before assuming the overhead is negligible.

Retry and timeout wrappers. These need to distinguish retryable from non-retryable failures and to interact correctly with cancellation. A retry decorator that swallows exceptions or retries non-idempotent work is a correctness bug wearing a convenience costume.

Caching. A memoization decorator must decide on a key function, handle unhashable arguments, and be explicit about whether the cache is per-process, per-instance, or global. Unbounded caches in a decorator are a memory leak with a friendly name.

Registration decorators. Here the decorator's return value may be irrelevant because the side effect — adding the function to a registry — is the point. This is a different pattern from behavior-changing decorators, and it deserves its own name, because the debugging questions are different. You are not tracing a wrapper; you are tracing a registration. The four-mechanics lens does not apply cleanly here, and pretending it does will send you looking for a wrapper that was never the problem.

AI and framework tooling. Decorators are how many libraries declare tools, endpoints, and handlers. When a framework inspects the decorated callable to build a schema, the wrapper's signature and metadata become part of the public contract. That is precisely why the earlier sections matter: the contract is not the function you wrote, it is the object the framework sees.

State the boundary honestly. Decorators compose behavior around a callable. They do not compose data flow, do not express ordering constraints between independent wrappers, and do not replace explicit dependency wiring when the behavior depends on runtime context.

The Four Mechanics, and One Thing to Do Next

Before writing a decorator, decide which of the four mechanics you are touching: the rebinding at definition time, the wrapper's call signature, the closure's captured state, or the metadata that tools read. That lens covers function-wrapping decorators well. When you are writing a registration decorator or a class-based decorator on a method, step outside it: the first is a side-effect pattern, and the second is a descriptor problem in disguise.

Then do this today. Take one decorator already in your codebase and print __name__, __doc__, __wrapped__, and inspect.signature on the decorated callable. Compare that output against what the original function would report. Any mismatch is a latent bug in a framework integration, a doc generator, or a debugger — and it is far cheaper to find now than in a traceback that points at the wrong line.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

A decorated callable has the name `wrapper`, lacks `__wrapped__`, and a framework reports an unexpected `(*args, **kwargs)` signature. Which debugging action should come first according to the article's procedure?
Question 1 of 2Comparison Reasoning

Focus: Apply the article's debugging sequence to separate metadata symptoms from argument-boundary failures.

Which statement best reflects the article's decision boundary for using decorators?
Question 2 of 2Misconception Check

Focus: Choose the appropriate abstraction when behavior depends on runtime decisions rather than definition-time decoration.

References

  1. GitHub - fluentpython/decorators-descriptors: Resources for "Decorators and Descriptors Decoded" PyCon US 2017 Tutorial · GitHubgithub.com
  2. Primer on Python Decoratorsrealpython.com
8sources checked
8source domains
10searches run

Research updated Sep 11, 2026

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.