Python Decorator Function Objects: The Attributes That Make Decorators Work

Decorators work because Python functions are objects. Not metaphorically -- they are instances of the function type with their own attributes, a writable dictionary, and a consistent internal structure that Python exposes through dunder attributes. When a decorator replaces a function with a wrapper, it replaces the entire object, including its __name__, __doc__, __code__, and __closure__. Understanding the function object model is what separates writing decorators that work from writing decorators that work correctly -- preserving identity, enabling introspection, and cooperating with frameworks that inspect function metadata at runtime.

Every def statement in Python creates a function object and assigns it to the function's name. That object carries a set of attributes that describe it: where it was defined, what it is called, what its source code compiles to, what default argument values it has, what variables it has captured from enclosing scopes, and what annotations are attached to it. These attributes are not hidden or special-purpose -- they are regular Python attributes accessible with dot notation and inspectable with dir(). Decorators -- which are fundamentally higher-order functions -- interact with every one of them.

The Anatomy of a Function Object

When you define a function, Python creates an object with a specific set of attributes. You can examine them by calling dir() on any function:

def calculate_area(length: float, width: float = 1.0) -> float:
    """Calculate the area of a rectangle."""
    return length * width


# Every function object carries these attributes
print(calculate_area.__name__)        # calculate_area
print(calculate_area.__qualname__)    # calculate_area
print(calculate_area.__doc__)         # Calculate the area of a rectangle.
print(calculate_area.__module__)      # __main__
print(calculate_area.__defaults__)    # (1.0,)
print(calculate_area.__kwdefaults__)  # None
print(calculate_area.__annotations__) # {'length': <class 'float'>, ...}
print(calculate_area.__code__)        # <code object calculate_area at 0x...>
print(calculate_area.__closure__)     # None
print(calculate_area.__dict__)        # {}

Each attribute serves a distinct role in how Python manages and executes the function. The following reference covers the attributes that are directly relevant to decorator behavior. Select any attribute to see its type and description:

__name__str

The function's name as written in the def statement.

__qualname__str

The qualified name, including class or enclosing function context (e.g., MyClass.method).

__doc__str or None

The docstring, or None if no docstring was provided.

__module__str

The name of the module where the function was defined.

__defaults__tuple or None

Default values for positional parameters.

__kwdefaults__dict or None

Default values for keyword-only parameters.

__annotations__dict

Type annotations from the function signature.

__type_params__tuple

Type parameter objects for generic functions (added in Python 3.12).

__code__code

The compiled bytecode object containing the function's instructions.

__closure__tuple or None

Cell objects holding values captured from enclosing scopes.

__dict__dict

A writable dictionary for user-defined attributes attached to the function.

__globals__dict

A reference to the module-level namespace where the function was defined.

These are not metadata that Python stores separately from the function. They are the function. The __code__ object contains the actual bytecode instructions. The __defaults__ tuple contains the actual default values that Python uses when arguments are omitted. The __closure__ tuple contains the actual cell objects that hold captured variables. When a decorator replaces a function with a wrapper, it replaces all of these attributes with the wrapper's attributes.

What Decoration Does to Function Attributes

A decorator replaces the original function object with a new one. Every attribute on the original is gone, replaced by the wrapper's attributes:

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


@log_calls
def calculate_area(length: float, width: float = 1.0) -> float:
    """Calculate the area of a rectangle."""
    return length * width


# After decoration, ALL attributes belong to the wrapper
print(calculate_area.__name__)        # wrapper
print(calculate_area.__qualname__)    # log_calls.<locals>.wrapper
print(calculate_area.__doc__)         # None
print(calculate_area.__defaults__)    # None
print(calculate_area.__annotations__) # {}
print(calculate_area.__module__)      # __main__

The name calculate_area now points to the wrapper function object. That object has its own __name__ ("wrapper"), its own __qualname__ ("log_calls.<locals>.wrapper"), no docstring, no default values, and no annotations. The original function still exists -- it is referenced by the func variable inside the wrapper's closure -- but the name calculate_area no longer provides access to its attributes.

This is not a cosmetic issue. Frameworks and tools rely on function attributes for real work. Flask uses __name__ for URL route registration. Sphinx reads __doc__ to generate documentation. The inspect module reads __annotations__ and __defaults__ to reconstruct the function signature. When decoration erases these attributes, those tools break silently or produce incorrect results.

Warning

If two decorated functions in a Flask application both end up with __name__ == "wrapper", Flask raises a routing error because it registers routes by function name. This is a direct consequence of decoration erasing the original __name__ attribute.

What Happens When You Stack Decorators

When multiple decorators are applied to a single function, Python applies them bottom-up. The decorator closest to the def statement runs first, wrapping the original function. The next decorator up wraps that result, and so on. Each layer creates a new function object with its own attributes, its own __code__, and its own __closure__ containing the previous layer. (For a focused guide on decorator stacking order, see the companion article.)

def decorator_a(func):
    def wrapper_a(*args, **kwargs):
        print("A before")
        result = func(*args, **kwargs)
        print("A after")
        return result
    return wrapper_a


def decorator_b(func):
    def wrapper_b(*args, **kwargs):
        print("B before")
        result = func(*args, **kwargs)
        print("B after")
        return result
    return wrapper_b


@decorator_a
@decorator_b
def greet():
    print("Hello")


greet()
# A before
# B before
# Hello
# B after
# A after

The stacking syntax @decorator_a on top of @decorator_b is equivalent to writing greet = decorator_a(decorator_b(greet)). The original greet function object gets passed to decorator_b first, which returns wrapper_b. That wrapper_b object is then passed to decorator_a, which returns wrapper_a. The name greet now points to wrapper_a, which calls wrapper_b, which calls the original greet.

This matters for function attributes because each layer without @wraps overwrites the identity. The final object's __name__ would be "wrapper_a". Its __closure__ would contain wrapper_b, not the original function. Only by applying @wraps(func) at each layer does the attribute chain remain coherent -- and only through __wrapped__ can tools like inspect.unwrap trace back through all the layers to the original.

Pro Tip

Think of stacked decorators as a call stack: the outermost decorator's "before" logic runs first, then each inner layer's "before" logic, then the original function, then each layer's "after" logic unwinds in reverse order. The object model mirrors this -- __closure__ at each layer points inward, and __wrapped__ (when @wraps is used) provides the same inward chain for introspection.

How functools.wraps Restores Identity

functools.wraps is a decorator applied to the wrapper function. It copies specific attributes from the original function onto the wrapper, restoring the illusion that the decorated function is still the original:

from functools import wraps


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


@log_calls
def calculate_area(length: float, width: float = 1.0) -> float:
    """Calculate the area of a rectangle."""
    return length * width


# With @wraps, the original attributes are restored
print(calculate_area.__name__)        # calculate_area
print(calculate_area.__qualname__)    # calculate_area
print(calculate_area.__doc__)         # Calculate the area of a rectangle.
print(calculate_area.__annotations__) # {'length': <class 'float'>, ...}
print(calculate_area.__module__)      # __main__

# @wraps also sets __wrapped__
print(calculate_area.__wrapped__)     # <function calculate_area at 0x...>

The @wraps(func) decorator calls functools.update_wrapper internally. By default, it copies these attributes from the original function: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__. It also updates __dict__ (merging the original's user-defined attributes into the wrapper's dictionary) and sets __wrapped__ to point to the original function.

The key detail is that @wraps does not change the wrapper's __code__ or __closure__. The wrapper still executes its own bytecode and holds its own closure variables. The copied attributes are surface-level identity markers that tell external tools what the function "is" -- its name, its documentation, its type hints -- without changing what the function does when called.

Note

@wraps does not copy __defaults__ or __kwdefaults__. The wrapper's (*args, **kwargs) signature is intentionally generic so that it can accept any arguments. The original function's default values are still applied when the wrapper calls func(*args, **kwargs) because the original function object is preserved in the closure.

Check Your Understanding
Test what you have learned so far about function objects and functools.wraps.
1. After applying a decorator without @wraps, what does decorated_func.__name__ return?
2. Which of these attributes does functools.wraps NOT copy from the original function?
3. What does the __wrapped__ attribute point to?

The __wrapped__ Attribute and Unwrapping

The __wrapped__ attribute, set by functools.wraps, provides a direct reference to the original, unwrapped function. This is useful for testing, debugging, and for tools that need to inspect the original function's true signature:

import inspect
from functools import wraps


def validate_positive(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg < 0:
                raise ValueError(f"Arguments must be positive, got {arg}")
        return func(*args, **kwargs)
    return wrapper


@validate_positive
def calculate_area(length: float, width: float = 1.0) -> float:
    """Calculate the area of a rectangle."""
    return length * width


# Access the original function via __wrapped__
original = calculate_area.__wrapped__
print(original.__name__)           # calculate_area
print(original(5, 3))              # 15.0 (no validation applied)
print(original(-2, 3))             # -6.0 (validation bypassed)

# inspect.signature follows __wrapped__ automatically
sig = inspect.signature(calculate_area)
print(sig)                         # (length: float, width: float = 1.0) -> float

# Without @wraps, inspect.signature would show (*args, **kwargs)

The inspect.signature function recognizes __wrapped__ and follows it to retrieve the original function's signature. This is what makes decorated functions display their correct parameter names in IDE tooltips, documentation generators, and interactive help. Without __wrapped__, the signature would show (*args, **kwargs), which tells the caller nothing about what arguments the function expects.

You can also use inspect.unwrap() to follow a chain of __wrapped__ references through multiple layers of decoration until reaching the innermost original function:

import inspect
from functools import wraps


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


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


@decorator_a
@decorator_b
def greet(name: str) -> str:
    """Return a greeting."""
    return f"Hello, {name}"


# The chain: greet -> decorator_a's wrapper -> decorator_b's wrapper -> original
print(greet.__name__)                        # greet (from @wraps)
print(greet.__wrapped__.__name__)            # greet (decorator_b's wrapper)
print(greet.__wrapped__.__wrapped__.__name__)# greet (original)

# inspect.unwrap follows the entire chain
original = inspect.unwrap(greet)
print(original is greet.__wrapped__.__wrapped__)  # True
Pro Tip

Use inspect.unwrap(func) in test suites to get the original function and test it without the decorator's behavior. This lets you verify the core logic independently of the wrapper's concerns (logging, validation, caching, and so on).

Using __dict__ to Attach State to Functions

Every function object has a __dict__ attribute -- a writable dictionary where you can store arbitrary key-value pairs. This makes functions themselves capable of carrying state, which is a technique decorators use to attach metadata, counters, and configuration directly to the function they wrap:

from functools import wraps


def count_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.call_count += 1
        print(f"[Call #{wrapper.call_count}] {func.__name__}")
        return func(*args, **kwargs)
    wrapper.call_count = 0
    return wrapper


@count_calls
def process_data(payload):
    return len(payload)


process_data([1, 2, 3])    # [Call #1] process_data
process_data([4, 5])        # [Call #2] process_data
process_data([6])            # [Call #3] process_data

print(process_data.call_count)   # 3
print(process_data.__dict__)     # {'call_count': 3}

The line wrapper.call_count = 0 stores an integer in the wrapper's __dict__. Each call to the wrapper increments it with wrapper.call_count += 1. Because @wraps(func) merges the original function's __dict__ into the wrapper's __dict__, any attributes set on the original function before decoration are also available on the decorated version.

This technique is an alternative to using nonlocal variables or mutable closure objects for maintaining state. The advantage is that the state is directly accessible and inspectable from outside the wrapper -- callers can read func.call_count or func.last_result without needing special accessor methods.

__dict__ vs nonlocal: Choosing a State Strategy

When a decorator needs to maintain mutable state across calls, there are two primary approaches. Using function attributes via __dict__ stores the state on the function object itself. Using nonlocal stores the state in the closure. The choice depends on whether external code needs to read or modify that state:

from functools import wraps


# Approach 1: State via __dict__ (externally visible)
def count_with_dict(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper


# Approach 2: State via nonlocal (encapsulated)
def count_with_nonlocal(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        return func(*args, **kwargs)
    calls = 0
    return wrapper


@count_with_dict
def task_a():
    pass


@count_with_nonlocal
def task_b():
    pass


task_a()
task_a()
print(task_a.calls)      # 2 -- directly accessible

task_b()
task_b()
# print(task_b.calls)    # AttributeError -- state is hidden in closure

The __dict__ approach makes state part of the function's public interface. Test code, monitoring dashboards, and other decorators can all read func.calls directly. The nonlocal approach encapsulates the state inside the closure, preventing external tampering but also preventing external inspection. Neither approach is universally better -- the right choice depends on whether the state is an implementation detail or part of the decorated function's intended behavior.

Spot the Bug
This decorator is supposed to cache results, but it has a flaw. Can you find it?
from functools import wraps def cache_results(func): @wraps(func) def wrapper(*args, **kwargs): key = args + tuple(kwargs.items()) if key not in func.cache: func.cache[key] = func(*args, **kwargs) return func.cache[key] func.cache = {} return wrapper @cache_results def expensive_query(user_id): print(f"Running query for {user_id}") return {"user": user_id, "data": "..."} expensive_query(42) expensive_query(42) # Should be cached -- but is it?
Where is the bug?

__closure__ and How Decorators Capture Variables

Every decorator that returns a nested function creates a closure. The __closure__ attribute on the wrapper holds the captured variables from the enclosing scope. Understanding this attribute explains how the wrapper retains access to the original function and to any configuration parameters captured by a decorator factory:

from functools import wraps


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


@repeat(num_times=3)
def say_hello(name):
    """Greet someone."""
    print(f"Hello, {name}")


# Inspect the closure
print(say_hello.__closure__)
# (<cell at 0x...: function object at 0x...>,
#  <cell at 0x...: int object at 0x...>)

# Each cell holds a captured variable
for i, cell in enumerate(say_hello.__closure__):
    print(f"Cell {i}: {cell.cell_contents!r}")
# Cell 0: <function say_hello at 0x...>   (the original func)
# Cell 1: 3                                 (the num_times parameter)

The wrapper's __closure__ is a tuple of cell objects. Each cell wraps a single variable captured from an enclosing scope. In this example, the wrapper captures two variables: func (the original say_hello function) and num_times (the integer 3). These cells are what allow the wrapper to call the original function and to know how many times to repeat -- even though the enclosing decorator and repeat functions have long since returned.

A function that is not a closure has __closure__ set to None. A decorator's wrapper always has a non-None __closure__ because it captures at least the original function from the enclosing scope.

Why __code__ Matters for Debugging

The __code__ attribute holds the compiled bytecode for the function. When you decorate a function, the __code__ on the resulting name is the wrapper's bytecode, not the original's. This affects debugging tools that report source locations based on __code__.co_filename and __code__.co_firstlineno:

from functools import wraps


def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper


@timer
def compute(n):
    """Compute the sum of squares up to n."""
    return sum(i * i for i in range(n))


# __name__ reports the original (thanks to @wraps)
print(compute.__name__)                    # compute

# __code__ belongs to the wrapper, not the original
print(compute.__code__.co_varnames)        # ('args', 'kwargs', 'time', ...)
print(compute.__wrapped__.__code__.co_varnames)  # ('n',)

# co_firstlineno shows where the code is defined
print(compute.__code__.co_firstlineno)             # line of wrapper def
print(compute.__wrapped__.__code__.co_firstlineno)  # line of original def

@wraps does not copy __code__ because doing so would make the wrapper execute the original function's bytecode directly, bypassing the wrapper logic entirely. The wrapper must retain its own __code__ to function. But this means that stack traces, profilers, and coverage tools see the wrapper's code object. The __wrapped__ reference allows these tools to follow the chain back to the original source when needed.

When functools.wraps Is Not Enough

functools.wraps handles the common case of a function decorator that wraps another function. But there are patterns where it falls short, and recognizing these edge cases prevents subtle bugs in production:

Class-based decorators are callable objects that use __init__ and __call__ instead of closure-based wrappers. Because the decorated result is a class instance rather than a function, it lacks a __name__ attribute by default. Applying functools.update_wrapper in __init__ copies the wrapped function's identity onto the instance, but the result still is not a function object -- it will not bind as a method when accessed on a class, and isinstance(result, types.FunctionType) returns False:

import functools
import types


class LogCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        return self.func(*args, **kwargs)

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


@LogCalls
def process(data):
    """Process the data."""
    return len(data)


# Identity attributes are restored
print(process.__name__)                        # process
print(process.__doc__)                         # Process the data.

# But the object is not a function
print(isinstance(process, types.FunctionType)) # False

The __get__ method in the example above solves the method-binding problem by implementing the descriptor protocol. Without it, using @LogCalls on a method inside a class would fail because the instance would not receive self as the first argument. This is a concern that closure-based decorators with @wraps handle automatically because functions are already descriptors.

Decorators that change the function signature create a mismatch between what __wrapped__ advertises and what the wrapper accepts. If a decorator adds, removes, or reorders parameters, inspect.signature will follow __wrapped__ to the original signature and display parameters that no longer correspond to the wrapper's behavior. In these cases, setting wrapper.__wrapped__ to None or deleting it after @wraps forces inspect.signature to read the wrapper's own signature instead:

import inspect
from functools import wraps


def inject_session(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        session = create_session()
        return func(session, *args, **kwargs)
    # The wrapper does NOT accept 'session' -- it injects it.
    # Advertising the original signature via __wrapped__ is misleading.
    del wrapper.__wrapped__
    return wrapper


@inject_session
def save_record(session, record_id, data):
    """Save a record using the provided session."""
    session.save(record_id, data)


# Without deleting __wrapped__, this would show (session, record_id, data)
# With __wrapped__ removed, it correctly shows (*args, **kwargs)
print(inspect.signature(save_record))
Note

Deleting __wrapped__ is a tradeoff. You lose the ability to reach the original function through inspect.unwrap, but you gain an honest signature. The better long-term solution for signature-altering decorators is to use inspect.Signature.replace to construct an accurate signature for the wrapper, then assign it to wrapper.__signature__. This gives callers the correct parameter information while preserving __wrapped__ for introspection tools that need it.

Key Takeaways

  1. Functions are objects with inspectable attributes. __name__, __doc__, __code__, __defaults__, __closure__, __annotations__, and __dict__ are all accessible with dot notation. These attributes define the function's identity, behavior, and metadata.
  2. Decoration replaces the entire function object. After @decorator, the name points to the wrapper, which has its own __name__ ("wrapper"), no docstring, no annotations, and no default values. The original function exists only inside the wrapper's closure.
  3. Stacking decorators creates layered objects. Each decorator wraps the result of the one below it. Without @wraps at every layer, the identity chain breaks. With it, __wrapped__ provides an unbroken path from the outermost wrapper back to the original function.
  4. functools.wraps copies identity attributes, not behavior attributes. It restores __name__, __qualname__, __doc__, __module__, __annotations__, __type_params__, and __dict__. It does not copy __code__, __closure__, or __defaults__ because those define what the wrapper does when called.
  5. __wrapped__ provides access to the original function. Set by functools.wraps, it lets inspect.signature retrieve the true parameter signature and lets inspect.unwrap follow a chain of decorators back to the original function.
  6. __dict__ enables attaching state to functions. Decorators can set attributes like wrapper.call_count = 0 to store mutable state directly on the function object, making it accessible to callers without special accessor methods. Choose __dict__ for externally visible state and nonlocal for encapsulated state.
  7. __closure__ is how decorators capture the original function. The wrapper's closure holds cell objects containing every variable captured from enclosing scopes, including the original function reference and any decorator configuration parameters.
  8. @wraps has limits. Class-based decorators, signature-altering decorators, and pickling all expose cases where @wraps alone is not sufficient. Understanding these edge cases is part of writing production-grade decorators.

The function object model is the foundation that makes Python's decorator pattern possible. Decoration works by replacing one function object with another. functools.wraps works by copying identity attributes from the old object to the new one. Introspection works because tools like inspect can read __wrapped__, __annotations__, and __code__ to reconstruct what the original function looked like before decoration. Every decorator you write interacts with these attributes whether you are aware of it or not. Making that interaction explicit -- by using @wraps, by understanding what gets copied and what does not, and by knowing how to inspect closures and code objects -- is what produces decorators that cooperate with the rest of the Python ecosystem.

How to Preserve Function Identity When Writing Python Decorators

Follow these steps to write decorators that preserve __name__, __doc__, __annotations__, and other attributes on the wrapped function.

  1. Inspect the original function's attributes. Use dir() and dot notation to examine __name__, __doc__, __code__, __defaults__, __closure__, __annotations__, and __dict__ on any function object.
  2. Understand what decoration erases. When a decorator replaces a function with a wrapper, the wrapper has its own __name__, __doc__, and __annotations__. The original attributes are lost unless explicitly copied.
  3. Apply @functools.wraps to the wrapper. Add @wraps(func) to your wrapper function. This copies __module__, __name__, __qualname__, __annotations__, __type_params__, __doc__, and merges __dict__ from the original onto the wrapper.
  4. Verify __wrapped__ and use inspect.unwrap. After applying @wraps, the wrapper gains a __wrapped__ attribute pointing to the original function. Use inspect.signature to verify the correct signature is advertised, and inspect.unwrap to follow decorator chains.
  5. Handle edge cases where @wraps is not enough. For class-based decorators, implement __get__ for method binding. For signature-altering decorators, delete __wrapped__ or set __signature__ explicitly to prevent misleading introspection.

Frequently Asked Questions

What attributes do Python function objects have?

Every Python function object carries attributes including __name__ (the function's name as a string), __doc__ (the docstring), __code__ (the compiled bytecode object), __defaults__ (default argument values), __kwdefaults__ (keyword-only defaults), __closure__ (captured variables from enclosing scopes), __dict__ (arbitrary user-attached attributes), __annotations__ (type hints), __module__ (the module where the function was defined), and __qualname__ (the qualified name for nested functions).

Why does decorating a function change its __name__ and __doc__?

When a decorator replaces a function with a wrapper, the name being decorated is rebound to the wrapper function object. The wrapper has its own __name__ (typically "wrapper") and its own __doc__ (None or the wrapper's docstring). The original function's attributes are not automatically transferred because Python treats the wrapper as a separate object.

How does functools.wraps fix decorated function attributes?

functools.wraps copies specific attributes from the original function onto the wrapper. By default it copies __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__, and updates __dict__. It also sets a __wrapped__ attribute on the wrapper that points back to the original function, enabling introspection tools to access the unwrapped function.

What is the __wrapped__ attribute on a decorated function?

__wrapped__ is an attribute set by functools.wraps that holds a reference to the original, unwrapped function. The inspect module recognizes __wrapped__ and can follow it to retrieve the original function's signature. You can call func.__wrapped__() directly to bypass the decorator's wrapper logic.

Can you attach custom attributes to a Python function?

Yes. Every function has a __dict__ attribute that stores user-defined attributes. You can set arbitrary attributes on a function with dot notation (func.my_attr = value) and access them later. Decorators use this capability to attach metadata like call counts, cache storage, or configuration flags directly to the function object.

What happens when you stack multiple decorators on one function?

Python applies stacked decorators bottom-up. The decorator closest to the def statement wraps the original function first. Each subsequent decorator wraps the result of the previous one. Without @wraps at every layer, the identity chain breaks. The outermost wrapper's __closure__ contains the next wrapper inward, and __wrapped__ (when @wraps is used) provides the same chain for introspection tools.

Should decorator state use __dict__ or nonlocal?

Use __dict__ (function attributes like wrapper.count = 0) when external code needs to read or modify the state. Use nonlocal when the state is an internal implementation detail that should be encapsulated inside the closure. __dict__ makes state part of the function's public interface; nonlocal hides it.

When is functools.wraps not enough?

functools.wraps falls short with class-based decorators (where the result is a class instance, not a function), decorators that alter the function signature (where __wrapped__ advertises incorrect parameters), and pickling scenarios. Class-based decorators need __get__ for method binding. Signature-altering decorators may need to delete __wrapped__ or set __signature__ explicitly.