TL;DR: Put @functools.wraps(func) on every wrapper function you write. Without it the decorated function reports the wrapper’s __name__, loses its docstring, and shows a (*args, **kwargs) signature. With it, Python copies the original’s identifying attributes across and stores the original as __wrapped__, so inspect.signature still finds the real function.

When you decorate a function, the name you defined ends up bound to a different object. The decorator returns a wrapper, Python binds your original name to that wrapper, and every tool that introspects the function — a traceback, help(), inspect.signature — reads the wrapper instead of the function you wrote.

Here is the smallest decorator that shows it. trace takes a function and returns a new function, wrapper, that calls through to the original:

import functools, inspect

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

@trace
def greet(name):
    "Return a greeting for name."
    return f"Hello, {name}"

The @trace line is shorthand for greet = trace(greet); like a list comprehension, it is sugar you can read back into ordinary code. After it runs, the name greet points at wrapper. The function you wrote is still there, but only as the object wrapper calls through to. Ask the name about itself and it answers as the wrapper:

>>> greet.__name__
'wrapper'
>>> greet.__doc__          # prints nothing: it is None
>>> inspect.signature(greet)
<Signature (*args, **kwargs)>

The name is 'wrapper', the docstring is None, and the signature is the wrapper’s (*args, **kwargs) rather than (name). Nothing copied the original function’s metadata onto the wrapper, so there is nothing for it to report but its own. help reads the same attributes, so it describes the wrapper too:

>>> help(greet)
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

In a traceback or in generated documentation, this function is now hard to tell apart from every other wrapper in the codebase.

Adding functools.wraps

functools.wraps is a decorator you apply to the wrapper. One line changes:

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

Decorate greet again with this version and it answers as itself:

>>> greet.__name__
'greet'
>>> greet.__doc__
'Return a greeting for name.'
>>> inspect.signature(greet)
<Signature (name)>
>>> greet.__wrapped__
<function greet at 0x...>

The name is back, the docstring is back, and the signature reports (name) again even though the wrapper is still literally defined as (*args, **kwargs). help finds the original everywhere it looks:

>>> help(greet)
Help on function greet in module __main__:

greet(name)
    Return a greeting for name.

The name and docstring came back together; the signature came back for a different reason.

What @wraps copies

The functools source lists exactly what gets copied. wraps is a thin wrapper over functools.update_wrapper, whose own one-line summary is “Update a wrapper function to look like the wrapped function.” It copies a fixed tuple of attributes from the original onto the wrapper — in Python 3.13:

WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
                       '__annotations__', '__type_params__')

__name__ and __doc__ are on that list, which is why they came back. update_wrapper also merges the original’s __dict__ (that is WRAPPER_UPDATES = ('__dict__',)) into the wrapper’s, so any attributes you had set on the function survive the wrapping.

The tuple is worth reading on your own interpreter rather than trusting a copy of it, because it does move between versions: 3.14 swapped __annotations__ for __annotate__, so on a current Python the same line reads ('__module__', '__name__', '__qualname__', '__doc__', '__annotate__', '__type_params__'). Whatever is in the tuple is what comes across.

The signature is the second thing, and it does not come from that tuple. update_wrapper runs one more line:

wrapper.__wrapped__ = wrapped

It stores the original function on the wrapper as __wrapped__. inspect.signature looks for that attribute and follows it, so with @wraps in place, asking the decorated function for its signature reaches past the wrapper’s (*args, **kwargs) to the real (name). The docs give the reason the reference is kept: “To allow access to the original function for introspection and other purposes (e.g. bypassing a caching decorator such as lru_cache), this function automatically adds a __wrapped__ attribute to the wrapper that refers to the function being wrapped.”

At call time the wrapper behaves the same with or without wraps; nothing about how the function runs changes. What wraps touches is only the function’s report of itself, and the __wrapped__ it leaves behind is the handle other code follows back to the original — inspect.signature uses it, and so does anything that needs to see past a wrapper like lru_cache. The functools documentation lists the full set of copied attributes.