Debugging Decorated Functions with Missing Metadata in Stack Traces

You hit an exception, scan the traceback, and every frame says wrapper. The logging output labels every call as wrapper. The help() function shows the wrong signature and no docstring. The pdb debugger drops you into a wrapper function instead of the code you wrote. This is what happens when decorators erase function metadata, and it turns a straightforward debugging session into a guessing game. This article walks through each symptom, shows what causes it, and demonstrates how to fix it—and how to diagnose the problem when it shows up in code you did not write.

The Symptoms: Where Missing Metadata Shows Up

A decorator without functools.wraps breaks function identity in every tool that inspects metadata. Here is a decorator that omits it, and the concrete consequences:

def broken_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@broken_decorator
def process_payment(amount, currency="USD"):
    """Charge the customer's payment method."""
    if amount <= 0:
        raise ValueError(f"Invalid amount: {amount}")
    return {"status": "charged", "amount": amount, "currency": currency}

Stack Traces

When process_payment(-5) raises a ValueError, the traceback shows:

Traceback (most recent call last):
  File "app.py", line 14, in <module>
    process_payment(-5)
  File "app.py", line 3, in wrapper
    return func(*args, **kwargs)
  File "app.py", line 9, in process_payment
    raise ValueError(f"Invalid amount: {amount}")
ValueError: Invalid amount: -5

The traceback includes a frame labeled wrapper that adds noise to every error report. In a codebase with dozens of decorated functions, every traceback will have one or more wrapper frames with no indication of which decorator produced them.

Logging

import logging
logger = logging.getLogger(__name__)

# If your logging decorator uses process_payment.__name__:
logger.info("Calling %s", process_payment.__name__)
# Output: Calling wrapper

Every log entry for every decorated function says "wrapper." You cannot filter, search, or group log entries by function name because they all have the same name.

help() and Documentation

help(process_payment)
# Help on function wrapper in module __main__:
#
# wrapper(*args, **kwargs)
#
# (no docstring)

The user sees wrapper(*args, **kwargs) instead of the real signature process_payment(amount, currency='USD'), and the docstring "Charge the customer's payment method." is gone. Interactive Python sessions, IDE tooltips, and auto-generated documentation all show useless information.

pickle Serialization

import pickle

pickle.dumps(process_payment)
# AttributeError: Can't pickle local object
#     'broken_decorator.<locals>.wrapper'

pickle serializes functions by their qualified name and module. The wrapper's __qualname__ is broken_decorator.<locals>.wrapper, which is a local function inside another function—not a top-level name that pickle can look up during deserialization. This breaks any system that tries to serialize function references, including multiprocessing task queues, distributed computing frameworks, and caching systems that store callable references.

The Fix: functools.wraps

Every symptom above is fixed by adding @functools.wraps(func) to the wrapper function inside the decorator:

import functools

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

@fixed_decorator
def process_payment(amount, currency="USD"):
    """Charge the customer's payment method."""
    if amount <= 0:
        raise ValueError(f"Invalid amount: {amount}")
    return {"status": "charged", "amount": amount, "currency": currency}

# All symptoms resolved:
print(process_payment.__name__)      # process_payment
print(process_payment.__doc__)       # Charge the customer's payment method.
print(process_payment.__qualname__)  # process_payment

# help() shows the correct signature and docstring
# pickle.dumps(process_payment) works
# Logging frameworks report the correct name

Knowing the one-line fix is the easy part. The harder skill is scanning code you did not write and seeing what is missing. The following challenge puts that skill to work immediately.

Spot the Bug

Three decorators are stacked on a single function. One of them is missing functools.wraps. Which decorator will cause process_order.__name__ to return the wrong value?

1import functools 2 3def validate_input(func): 4 @functools.wraps(func) 5 def validate_wrapper(*args, **kwargs): 6 if not args: 7 raise ValueError("No arguments provided") 8 return func(*args, **kwargs) 9 return validate_wrapper 10 11def log_call(func): 12 def log_wrapper(*args, **kwargs): 13 print(f"Calling {func.__name__}") 14 return func(*args, **kwargs) 15 return log_wrapper 16 17def retry(max_attempts=3): 18 def decorator(func): 19 @functools.wraps(func) 20 def retry_wrapper(*args, **kwargs): 21 for attempt in range(max_attempts): 22 try: 23 return func(*args, **kwargs) 24 except Exception: 25 if attempt == max_attempts - 1: 26 raise 27 return retry_wrapper 28 return decorator 29 30@validate_input 31@log_call 32@retry(max_attempts=3) 33def process_order(order_id): 34 """Submit an order for processing.""" 35 return {"order": order_id, "status": "submitted"}
Not quite.

Look again at lines 3-9. The validate_input decorator has @functools.wraps(func) on line 4, so it correctly preserves the metadata of whatever function it wraps. The bug is in a different decorator. Look for the one that defines an inner function but never calls functools.wraps.

Correct.

The log_call decorator on lines 11-15 is missing @functools.wraps(func). Its inner function log_wrapper replaces the wrapped function without copying metadata. But notice the subtlety in the stacking order: validate_input is the outermost decorator and does use functools.wraps, so it copies the metadata from whatever it receives. But what it receives is log_wrapper, whose __name__ is already "log_wrapper" instead of "process_order". So validate_input faithfully copies the wrong metadata. The result: process_order.__name__ returns "log_wrapper". A single broken link in the chain corrupts everything above it.

Not quite.

The retry decorator on lines 17-28 has @functools.wraps(func) on line 19, so it correctly preserves metadata. This is a decorator factory (it takes max_attempts as a parameter and returns the actual decorator), but the inner decorator still applies functools.wraps properly. The bug is in a different decorator—the one without any functools.wraps call at all.

That exercise demonstrates something important: a missing functools.wraps in any single decorator poisons the entire chain above it. Even if every other decorator uses functools.wraps perfectly, the metadata coming through the broken link is already wrong. This is why it matters to understand exactly what functools.wraps does and does not fix.

What functools.wraps Fixes and What It Cannot

functools.wraps copies __name__, __doc__, __qualname__, __annotations__, __module__, and __type_params__ from the original function to the wrapper. It merges __dict__ and sets __wrapped__. This fixes every tool that reads these attributes: help(), logging, repr(), pickle, documentation generators, and inspect.signature() (which follows __wrapped__).

There is one thing functools.wraps does not change: the code object's co_name. The code object is the compiled bytecode of the wrapper function, and its co_name is set at compile time to whatever the def statement named the function. Python's traceback mechanism uses co_name when formatting stack frames, not __name__. This means the traceback frame line will still show the wrapper's definition name:

# Even with @functools.wraps, the traceback frame still shows "wrapper":
# File "app.py", line 5, in wrapper    <-- co_name from the def statement
#     return func(*args, **kwargs)

# But __name__ is now correct:
print(process_payment.__name__)  # process_payment
Note

The traceback's frame name (co_name) and the function's __name__ attribute are different things. functools.wraps fixes __name__ but not co_name. In practice, this means the traceback still shows the wrapper's name on the frame line, but every other tool (logging, help, pickle, inspect) uses the corrected __name__. To get the original function name in the traceback itself, you would need to replace the code object or use a library like wrapt that generates a new wrapper with the correct co_name.

The distinction between __name__ and co_name is one of the subtler corners of Python's function model. The following questions test whether the nuances from this section have landed.

Check Your Understanding

1. A decorator uses @functools.wraps(func). The decorated function raises an exception. What name appears in the traceback frame for the wrapper?

Not quite.

This is a common misconception. functools.wraps does fix __name__, and that fixes logging, help(), pickle, and inspect.signature(). But Python tracebacks do not read __name__. They read the code object's co_name, which is baked in at compile time from the def statement. The traceback frame will still show the wrapper's definition name.

Correct.

Tracebacks use the code object's co_name, which is set at compile time from the def statement. functools.wraps changes __name__ but does not touch co_name. So the traceback frame will say wrapper (or whatever the inner function was named), even though func.__name__ returns the original name. This is why naming your wrapper functions descriptively (like auth_required_wrapper) matters for traceback readability.

Not quite.

Python wrapper functions are not anonymous. They are defined with a def statement inside the decorator (like def wrapper(*args, **kwargs)), and that def name becomes the function's co_name in its code object. The traceback will show that name. Only lambda expressions produce functions named <lambda>.


2. You are debugging a function that has been decorated three times. You run inspect.unwrap(func) and it returns a function whose __name__ is "wrapper". What does this tell you?

Not quite.

If all three decorators were missing functools.wraps, there would be no __wrapped__ attribute on the outermost function at all, and inspect.unwrap would return the outermost wrapper immediately. The fact that it traveled partway down and returned a function named "wrapper" means at least one decorator above the break used functools.wraps and set __wrapped__, but the chain stopped at a decorator that did not.

Correct.

inspect.unwrap follows the __wrapped__ attribute chain until it finds a function without __wrapped__. If a decorator in the middle does not use functools.wraps, it does not set __wrapped__, so the chain stops there. The function returned is the wrapper from that broken decorator, not the original function. Its name being "wrapper" confirms it stopped at a wrapper whose metadata was never fixed.

Not quite.

inspect.unwrap does the opposite: it tries to reach the innermost original function by following __wrapped__ inward, not outward. It starts at the outermost wrapper and keeps following __wrapped__ until it reaches a function without that attribute. If it returns "wrapper", the chain was broken before reaching the original.


3. A colleague argues that functools.wraps is unnecessary because they never use pickle or help(). Which of the following is the strongest reason they should still use it?

Not quite.

This is the one thing functools.wraps does not do. As covered in this section, tracebacks use co_name from the code object, which functools.wraps does not change. The traceback frame still shows the wrapper's definition name. The real benefits are in the other tools: logging, inspect, testing, and the __wrapped__ chain.

Correct.

The __wrapped__ attribute is arguably the most important thing functools.wraps provides. It creates a chain that inspect.signature() follows to show the correct parameter signature, that inspect.unwrap() follows for debugging, and that test frameworks can use to call the original function directly. The corrected __name__ also means logging frameworks report the real function name instead of "wrapper". These benefits apply to every codebase, not just those using pickle or help().

Not quite.

PEP 8 is Python's style guide, and it does not mandate the use of functools.wraps. Using it is a widely recommended best practice, but it is not a PEP 8 rule. The real reason to use it is the concrete functionality it provides: the __wrapped__ chain, correct __name__ for logging, and correct signatures for introspection tools.

Score: 0 / 3

Diagnosing Broken Decorator Chains

When you encounter missing metadata in existing code, you need to figure out which decorator in the chain is responsible. Here is a diagnostic function that inspects a decorated function and reports what it finds:

import inspect

def diagnose_decorator(func):
    """Print diagnostic information about a decorated function."""
    print(f"__name__:     {getattr(func, '__name__', 'MISSING')}")
    print(f"__qualname__: {getattr(func, '__qualname__', 'MISSING')}")
    print(f"__doc__:      {getattr(func, '__doc__', 'MISSING')}")
    print(f"__module__:   {getattr(func, '__module__', 'MISSING')}")
    print(f"__wrapped__:  {hasattr(func, '__wrapped__')}")

    # Follow the __wrapped__ chain
    depth = 0
    current = func
    while hasattr(current, '__wrapped__'):
        depth += 1
        current = current.__wrapped__
        print(f"  Layer {depth}: {current.__name__} "
              f"(qualname: {current.__qualname__})")

    if depth == 0:
        print("  No __wrapped__ chain found.")
        print("  The outermost decorator may be missing functools.wraps.")
    else:
        print(f"  Original function: {current.__name__}")

    # Check if inspect.unwrap reaches the same result
    try:
        unwrapped = inspect.unwrap(func)
        print(f"  inspect.unwrap -> {unwrapped.__name__}")
    except ValueError as e:
        print(f"  inspect.unwrap failed: {e}")

# Example usage:
diagnose_decorator(process_payment)

This function checks three things: whether the function's __name__ and __qualname__ look like a wrapper name, whether a __wrapped__ chain exists (indicating at least the outermost decorator used functools.wraps), and whether inspect.unwrap() can reach the innermost original function.

When the chain is broken at a specific layer, the diagnostic output will show that __wrapped__ stops at that layer. The decorator immediately above the break point is the one that omitted functools.wraps.

Pro Tip

A quick one-liner to check if any function has lost its metadata: print(my_func.__name__, hasattr(my_func, '__wrapped__')). If the name is "wrapper" (or similar) and __wrapped__ is False, the decorator did not use functools.wraps.

You now have two tools for diagnosing broken chains: manual inspection of __name__ and __wrapped__, and the diagnose_decorator utility. Before moving on to pdb, test your ability to trace through a decorator chain mentally. This is the skill that separates reading about decorators from reasoning about them.

What Would Python Print?

Study this code, then predict what each print statement outputs. Think through the decorator application order before selecting your answer.

1import functools 2 3def alpha(func): 4 @functools.wraps(func) 5 def alpha_inner(*a, **kw): 6 return func(*a, **kw) 7 return alpha_inner 8 9def beta(func): 10 def beta_inner(*a, **kw): 11 return func(*a, **kw) 12 return beta_inner 13 14@alpha 15@beta 16def target(): 17 """The real function.""" 18 pass

What does print(target.__name__) output?

Not quite.

This is the intuitive answer, but trace the decorator application order. Decorators apply bottom to top: beta wraps target first, returning beta_inner. Since beta does not use functools.wraps, beta_inner.__name__ is just "beta_inner", not "target". Then alpha wraps beta_inner. Even though alpha uses @functools.wraps(func), it copies __name__ from its input, which is now "beta_inner". The original name "target" was already lost when beta dropped it.

Correct.

Decorators apply bottom to top. First, beta wraps target and returns beta_inner. Since beta does not use functools.wraps, beta_inner.__name__ is "beta_inner". Then alpha wraps beta_inner. alpha uses @functools.wraps(func), which copies func.__name__ to its wrapper. But func is beta_inner, so alpha copies the name "beta_inner". The original name "target" was already lost. This is chain-poisoning in action: functools.wraps can only preserve metadata that still exists on the function it receives.

Not quite.

Close thinking, but alpha uses @functools.wraps(func), which overwrites alpha_inner.__name__ with func.__name__. Since alpha receives beta_inner as its func argument, it copies "beta_inner" onto its own wrapper. The name "alpha_inner" would only appear in co_name (in tracebacks), not in __name__.


Now, what does print(hasattr(target, '__wrapped__')) output?

Not quite.

__wrapped__ does exist because alpha uses functools.wraps. But __wrapped__ points to the function that alpha received as input, which is beta_inner, not the original target. The __wrapped__ attribute only reaches back one layer. Since beta broke the chain by not setting its own __wrapped__, there is no path from beta_inner back to the original.

Correct.

alpha uses functools.wraps, so it sets __wrapped__ on its wrapper. But __wrapped__ points to the function alpha received, which is beta_inner. And since beta did not use functools.wraps, beta_inner has no __wrapped__ attribute. So the chain is: target (actually alpha_inner) has __wrapped__ = beta_inner, and beta_inner has no __wrapped__. If you called inspect.unwrap(target), you would get back beta_inner, not the real target. The chain is broken.

Not quite.

alpha does use functools.wraps, and one of the things functools.wraps does is set __wrapped__. So hasattr(target, '__wrapped__') is True. The real question is what it points to: since alpha wraps the output of beta (which is beta_inner), target.__wrapped__ is beta_inner, not the original function.

Debugging Decorated Functions in pdb

When you use pdb (or breakpoint()) to step through decorated code, you will encounter the wrapper function before reaching your actual function. Understanding the navigation pattern helps you move through decorator layers efficiently:

import functools

def auth_required(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # pdb will stop here first
        print("Checking auth...")
        return func(*args, **kwargs)
    return wrapper

@auth_required
def get_secret():
    """Return classified data."""
    breakpoint()  # pdb stops here on the second "step"
    return {"secret": 42}

get_secret()
# In pdb:
# > app.py(6)wrapper()      <-- you land in the wrapper first
# -> return func(*args, **kwargs)
# (Pdb) step                 <-- step INTO the func() call
# > app.py(13)get_secret()   <-- now you are in the real function
# -> return {"secret": 42}

The step command enters the func(*args, **kwargs) call inside the wrapper, which takes you into the original function. The next command would step over it and stay in the wrapper. If the decorator used functools.wraps, you can also access the original function via __wrapped__, bypassing the wrapper entirely.

When dealing with multiple stacked decorators, the where command (w in pdb) prints the full stack trace, showing every wrapper layer between the caller and your function. Each wrapper frame will be labeled with the wrapper's co_name, which is the name used in the def statement inside the decorator. If all decorators named their inner function wrapper, the stack trace will show a column of identical wrapper frames with no way to tell them apart. This is one reason to give wrapper functions descriptive names inside your decorators:

import functools

def auth_required(func):
    @functools.wraps(func)
    def auth_required_wrapper(*args, **kwargs):  # descriptive name
        return func(*args, **kwargs)
    return auth_required_wrapper

def rate_limit(func):
    @functools.wraps(func)
    def rate_limit_wrapper(*args, **kwargs):  # descriptive name
        return func(*args, **kwargs)
    return rate_limit_wrapper

# Now tracebacks show:
# File "app.py", line 5, in auth_required_wrapper
# File "app.py", line 11, in rate_limit_wrapper
# instead of two identical "wrapper" frames
Warning

Even with descriptive wrapper names, functools.wraps copies the original function's __name__ onto the wrapper. This means wrapper.__name__ returns the original function's name, not the descriptive wrapper name. The descriptive name only appears in tracebacks (via co_name). Both pieces of information are useful: __name__ tells you which function was decorated, and co_name tells you which decorator produced the frame.

How to Fix Missing Function Metadata in Python Decorated Functions

  1. Identify the symptoms. Check if your function's __name__ returns "wrapper" instead of the original name. Look for wrapper frames in stack traces, incorrect names in logging output, missing docstrings in help(), and PicklingError when serializing the function.
  2. Add functools.wraps to the decorator. Import functools and add @functools.wraps(func) as a decorator on the inner wrapper function inside your decorator, directly above the def statement of the wrapper. This copies __name__, __doc__, __qualname__, and other attributes from the original function to the wrapper.
  3. Diagnose broken decorator chains. If the function has multiple stacked decorators, check which one broke the chain by inspecting __name__ and __wrapped__. Use inspect.unwrap() to follow the __wrapped__ chain. Where the chain stops is where the missing functools.wraps is.
  4. Name wrapper functions descriptively. Instead of naming every inner function wrapper, use descriptive names like auth_required_wrapper or rate_limit_wrapper. These names appear in tracebacks via co_name and help distinguish which decorator produced each frame.
  5. Navigate decorated functions in pdb. Use the step command to enter through wrapper layers and the where command to see the full stack. Use up and down to move between frames. Access the original function via __wrapped__ to set breakpoints directly.

Frequently Asked Questions

Why does my Python stack trace show "wrapper" instead of my function name?

When a decorator wraps a function without using functools.wraps, the wrapper function replaces the original. Python's traceback displays the function name from the code object's co_name, which is whatever the def statement named the inner function—typically wrapper. Adding @functools.wraps(func) fixes the __name__ attribute but does not change co_name, so tracebacks will still show the wrapper's name in the frame header while func.__name__ returns the correct name.

How do I find which decorator is hiding my function's metadata?

Check the function's __name__ attribute. If it shows "wrapper" or another unexpected name, a decorator in the chain is missing functools.wraps. Check for a __wrapped__ attribute: if it exists, at least the outermost decorator used wraps. Use inspect.unwrap() to follow the __wrapped__ chain. Where the chain stops is where the decorator omitted functools.wraps.

Does functools.wraps fix the function name in tracebacks?

functools.wraps copies __name__ and __qualname__ to the wrapper, which fixes the name reported by help(), logging, repr(), and direct attribute access. However, Python tracebacks use the code object's co_name (from compiled bytecode), which functools.wraps does not modify. To get descriptive names in tracebacks, name your wrapper functions descriptively (like auth_required_wrapper instead of wrapper).

How do I debug a decorated function in pdb?

When stepping through code in pdb, you will enter the wrapper function before reaching the original. Use the step command to step into the func(*args, **kwargs) call inside the wrapper. Use where to see the full stack trace and identify which frames are wrappers. If the decorator used functools.wraps, access the original function via __wrapped__ to set breakpoints directly.

Why does pickle fail on my decorated function?

pickle serializes functions by their qualified name and module. If a decorator replaces your function with a wrapper whose __qualname__ is something like broken_decorator.<locals>.wrapper, pickle cannot look up that local name during deserialization and raises an error. Using functools.wraps fixes __name__ and __qualname__, allowing pickle to locate the function correctly.

Key Takeaways

  1. Missing metadata manifests everywhere: Without functools.wraps, stack traces show "wrapper" in frame names, logging output reports the wrong function name, help() shows the wrong signature and no docstring, and pickle fails to serialize the function.
  2. functools.wraps fixes __name__, __doc__, __qualname__, and adds __wrapped__: This restores correct behavior for logging, help, pickle, inspect.signature, and any tool that reads function attributes. It does not change the code object's co_name, which is used in traceback frame labels.
  3. Give wrapper functions descriptive names: Instead of naming every inner function wrapper, use names like auth_required_wrapper or rate_limit_wrapper. These names appear in tracebacks via co_name and help distinguish which decorator produced each frame in a stacked trace.
  4. Diagnose broken chains by checking __name__ and __wrapped__: If a function's __name__ is "wrapper" and it lacks a __wrapped__ attribute, the outermost decorator is missing functools.wraps. Use inspect.unwrap() to follow the chain and find where it breaks.
  5. In pdb, use step to enter through wrappers and where to see the full stack: Each decorator adds a frame to the stack. The step command enters the func(*args, **kwargs) call inside the wrapper, taking you to the next layer or the original function. The up and down commands move between frames for inspection.
  6. Prevention is easier than diagnosis: Adding @functools.wraps(func) to every decorator you write takes one line of code per decorator and prevents the entire class of debugging problems described in this article. Make it a non-negotiable part of your decorator template.

The next time you see a stack trace full of wrapper frames, you will know exactly what happened: a decorator replaced your function's identity without preserving it. The fix is one import and one line of code. The prevention is making @functools.wraps(func) an automatic habit in every decorator you write.