Python's functools module provides two tools for preserving function metadata across decorators: functools.wraps and functools.update_wrapper. They do the same thing. They copy the same attributes. They produce identical results. The difference is entirely in how they are applied. wraps is a decorator you place on the inner wrapper function with @ syntax. update_wrapper is a function you call imperatively after the wrapper already exists. Understanding when to reach for each one eliminates a recurring point of confusion in Python decorator code.
The Short Answer
functools.wraps is a convenience wrapper around functools.update_wrapper. It exists so you can apply metadata copying as a decorator with @ syntax instead of making a separate function call. Both produce identical output.
Tap each row below to compare them side by side:
@functools.wraps(func)functools.update_wrapper(wrapper, func)def)@ syntax.__module__, __name__, __qualname__, __annotations__, __type_params__ (3.12+), __doc__. Updated: __dict__. Always sets __wrapped__.wraps calls partial(update_wrapper, ...). update_wrapper is the actual implementation doing the work.Which statement correctly describes the relationship between functools.wraps and functools.update_wrapper?
What update_wrapper Does
functools.update_wrapper is the function that does the real work. It takes two required arguments—the wrapper and the wrapped function—and copies metadata from the wrapped function onto the wrapper. Here is its signature:
functools.update_wrapper(
wrapper, # the object to update
wrapped, # the original function
assigned=WRAPPER_ASSIGNMENTS, # attributes to overwrite
updated=WRAPPER_UPDATES, # attributes to merge
)
The function performs three operations in order. First, it iterates over the assigned tuple and copies each attribute from the wrapped function to the wrapper using setattr. If an attribute is missing on the wrapped function, it is silently skipped (this changed in Python 3.2; prior to that, missing attributes raised AttributeError). Second, it iterates over the updated tuple and merges each attribute using dict.update. Third, it sets wrapper.__wrapped__ = wrapped to create the reference back to the original function.
Here is a simplified version of the CPython implementation that shows exactly what happens:
# Simplified from CPython's Lib/functools.py
# WRAPPER_ASSIGNMENTS as of Python 3.12 – 3.14 (per official docs):
WRAPPER_ASSIGNMENTS = (
'__module__', '__name__', '__qualname__',
'__annotations__', '__type_params__', '__doc__',
)
WRAPPER_UPDATES = ('__dict__',)
# Note: CPython 3.14 main branch also handles __annotate__ as part of
# PEP 649/749 (issue #124342). This is an addition alongside __annotations__,
# not a replacement. See the version history section for full details.
def update_wrapper(wrapper, wrapped,
assigned=WRAPPER_ASSIGNMENTS,
updated=WRAPPER_UPDATES):
# Step 1: Copy assigned attributes (overwrite)
for attr in assigned:
try:
value = getattr(wrapped, attr)
except AttributeError:
pass
else:
setattr(wrapper, attr, value)
# Step 2: Merge updated attributes (dict.update)
for attr in updated:
getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
# Step 3: Set __wrapped__ last to avoid copying it
# from the wrapped function's __dict__ during Step 2
wrapper.__wrapped__ = wrapped
return wrapper
The comment in CPython's source ("set __wrapped__ last so we don't inadvertently copy it from the wrapped function when updating __dict__") addresses a subtle bug fixed in Python 3.4. If a function already has a __wrapped__ attribute in its __dict__ (because it was itself decorated), the __dict__ merge in Step 2 would copy that stale reference. Setting __wrapped__ after the merge ensures the attribute always points to the immediate wrapped function, not to a function further down the chain. Before this fix, stacking multiple decorators could cause __wrapped__ to bypass intermediate layers and point straight to the innermost function.
How wraps Calls update_wrapper
functools.wraps is implemented as a single line of code in CPython. It uses functools.partial to pre-fill the wrapped, assigned, and updated arguments of update_wrapper, and returns the resulting partial object as a decorator:
# Actual CPython implementation of functools.wraps
def wraps(wrapped,
assigned=WRAPPER_ASSIGNMENTS,
updated=WRAPPER_UPDATES):
return partial(update_wrapper, wrapped=wrapped,
assigned=assigned, updated=updated)
When you write @functools.wraps(func) above a function definition, here is what happens step by step:
import functools
def my_decorator(func):
# Step 1: wraps(func) returns partial(update_wrapper, wrapped=func)
# Step 2: That partial is applied as a decorator to wrapper
# Step 3: partial calls update_wrapper(wrapper, wrapped=func)
# Step 4: update_wrapper copies metadata and returns wrapper
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# The above is exactly equivalent to:
def my_decorator_explicit(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
functools.update_wrapper(wrapper, func)
return wrapper
Both decorators produce identical results. The only difference is readability: @functools.wraps(func) is one line at the point of definition, while the update_wrapper call is a separate statement after the wrapper is defined. For function-based decorators, wraps is the conventional choice.
A useful way to confirm this relationship at the Python prompt:
import functools
# wraps(func) returns a functools.partial object
decorator = functools.wraps(lambda: None)
print(type(decorator)) # <class 'functools.partial'>
print(decorator.func) # <function update_wrapper at 0x...>
# Confirm: wraps.func IS update_wrapper
print(decorator.func is functools.update_wrapper) # True
What does functools.wraps(func) return when called?
When to Use wraps (Function-Based Decorators)
functools.wraps is the right choice for the standard function-based decorator pattern, where the decorator defines an inner function and returns it as the wrapper. This covers the vast majority of decorators:
import functools
import time
def timer(func):
"""Measure and print execution time."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def process(items):
"""Process a list of items."""
return [item * 2 for item in items]
print(process.__name__) # process
print(process.__doc__) # Process a list of items.
This also works correctly inside parameterized decorators (decorator factories). Apply @functools.wraps(func) to the innermost wrapper—the function that replaces the original. A common mistake is applying wraps at the wrong nesting level:
import functools
def retry(max_attempts=3):
def decorator(func):
@functools.wraps(func) # correct: applied to innermost wrapper
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_attempts:
raise
return wrapper
return decorator
# WRONG pattern (missing wraps on innermost):
def retry_broken(max_attempts=3):
@functools.wraps(lambda: None) # wrong level — decorating decorator, not wrapper
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
@retry(max_attempts=5)
def connect(host):
"""Establish a connection to a remote host."""
pass
print(connect.__name__) # connect
When to Use update_wrapper (Class-Based Decorators)
functools.update_wrapper is the correct choice when the wrapper is not a function being defined with def. The primary case is class-based decorators, where the class instance is the wrapper:
import functools
class CountCalls:
"""Track how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func) # copy metadata to self
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
"""Say hello to someone."""
return f"Hello, {name}"
print(greet.__name__) # greet
print(greet.__doc__) # Say hello to someone.
print(greet.__wrapped__ is greet.func) # True
print(greet.count) # 0 initially
greet("Ada")
print(greet.count) # 1
You cannot use @functools.wraps(func) in this context because there is no inner function to decorate. The class instance itself is the wrapper, and it already exists as self inside __init__. Calling functools.update_wrapper(self, func) copies metadata from func onto the class instance directly.
When update_wrapper copies __dict__ from the original function onto self, it merges the function's attribute dictionary into the instance. If the original function has attributes with names that collide with class attributes or properties, those class-level attributes can be silently overwritten. Inspect the merged result with vars(greet) if unexpected attributes appear.
Patching Third-Party Decorators
update_wrapper is also the right tool when you need to fix metadata on a wrapper that was created by someone else's code:
import functools
# A third-party decorator that forgot functools.wraps
def broken_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def fix_decorator(bad_dec):
"""Wrap a broken decorator to preserve metadata."""
@functools.wraps(bad_dec)
def fixed(func):
result = bad_dec(func)
functools.update_wrapper(result, func)
return result
return fixed
good_decorator = fix_decorator(broken_decorator)
@good_decorator
def multiply(a, b):
"""Multiply two numbers."""
return a * b
print(multiply.__name__) # multiply
print(multiply.__doc__) # Multiply two numbers.
If you are writing a def inside a def, use @functools.wraps(func). If you are writing a class with __init__ and __call__, use functools.update_wrapper(self, func). If you are fixing something after the fact, use functools.update_wrapper(wrapper, original).
You are writing a class-based decorator. Inside __init__, your instance (self) is the wrapper. Which is the correct way to copy metadata from the original function?
The __wrapped__ Attribute and inspect.signature()
One of the less-documented effects of both wraps and update_wrapper is that they always set __wrapped__ on the wrapper, regardless of the assigned configuration. This attribute is the key that connects the Python introspection system to the original function.
As of Python 3.5, inspect.signature() automatically follows __wrapped__ chains to return the signature of the original function rather than the generic (*args, **kwargs) signature of the wrapper:
import functools
import inspect
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(x: int, y: int = 0) -> int:
"""Add two integers."""
return x + y
# inspect.signature follows __wrapped__ automatically (Python 3.5+)
print(inspect.signature(add)) # (x: int, y: int = 0) -> int
print(add.__wrapped__) #
# inspect.unwrap follows the full chain to the bottom
print(inspect.unwrap(add)) # the original unwrapped add function
The inspect.unwrap() function, added in Python 3.4, traverses the full __wrapped__ chain link by link until it reaches a function with no __wrapped__ attribute. It also raises ValueError if it detects a cycle in the chain—a safeguard added precisely because __wrapped__ is set by convention and can theoretically be set to anything, including a circular reference.
The Signature Problem functools.wraps Does Not Fully Solve
There is a subtlety that many tutorials omit. functools.wraps does not copy __defaults__ or __kwdefaults__ from the original function. This is intentional: class-based wrappers do not have those attributes, and forcing them into WRAPPER_ASSIGNMENTS would break update_wrapper when used with class instances.
The Python documentation describes functools.wraps as "a convenience function for invoking update_wrapper() as a function decorator when defining a wrapper function" and confirms it is "equivalent to partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated)."
The practical consequence: if your wrapper uses *args, **kwargs and a caller inspects its signature without following __wrapped__, they see the generic signature. The fix depends on how strict you need to be:
import functools
import inspect
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name: str, greeting: str = "Hello") -> str:
"""Greet someone."""
return f"{greeting}, {name}"
# inspect.signature follows __wrapped__ — reports the original (Python 3.5+)
print(inspect.signature(greet))
# (name: str, greeting: str = 'Hello') -> str
# Direct attribute access does NOT follow __wrapped__
print(greet.__defaults__) # None (wrapper's defaults, not greet's)
# To access original defaults, use __wrapped__
print(greet.__wrapped__.__defaults__) # ('Hello',)
# Or extend WRAPPER_ASSIGNMENTS to include them
EXTENDED = functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__')
def my_decorator_full(func):
@functools.wraps(func, assigned=EXTENDED)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator_full
def greet2(name: str, greeting: str = "Hello") -> str:
"""Greet someone (full copy)."""
return f"{greeting}, {name}"
print(greet2.__defaults__) # ('Hello',)
Frameworks that dispatch based on function signature—Flask route matching, pytest fixture injection, Pyramid view configuration—call inspect.signature() on your decorated functions. If the wrapper's *args, **kwargs signature reaches the framework instead of the original signature, dispatching can behave incorrectly. Since Python 3.5, inspect.signature() follows __wrapped__ automatically, so functools.wraps protects you from this class of bug in modern Python. In Python 3.4, you had to call inspect.unwrap() manually to get the same result.
Stopping unwrap Early with __signature__
Sometimes you want inspect.signature() to report a specific signature rather than the original function's. Set __signature__ explicitly on the wrapper; inspect.signature() stops following the __wrapped__ chain as soon as it finds a __signature__ attribute:
import functools
import inspect
def get_context():
"""Placeholder: returns whatever context the application provides."""
return {}
def inject_context(func):
"""Inject a 'ctx' argument automatically; callers do not provide it."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
kwargs['ctx'] = get_context() # injected at call time
return func(*args, **kwargs)
# Override reported signature so callers see the correct interface
original_sig = inspect.signature(func)
params = [p for p in original_sig.parameters.values()
if p.name != 'ctx']
wrapper.__signature__ = original_sig.replace(parameters=params)
return wrapper
Python Version History: What Changed and When
The behavior of update_wrapper and wraps has evolved across Python releases. Knowing this history matters when maintaining code that runs on older interpreters or when debugging unexpected behavior in version-specific environments.
__wrapped__ was added to WRAPPER_ASSIGNMENTS automatically for the first time in Python 3.2, and __annotations__ was also added to the default copied attributes. Missing attributes on the wrapped function no longer raised AttributeError—they are silently skipped. Before 3.2, you had to set __wrapped__ manually if you needed it.
inspect.unwrap() was introduced. The __wrapped__ ordering bug was fixed: the attribute is now set after the __dict__ merge so it always points to the immediate wrapper, not to a stale reference inherited from the wrapped function's own __dict__. The Python docs confirm that __wrapped__ now reliably refers to the wrapped function even when the wrapped function itself defines that attribute.
inspect.signature() gained the ability to follow __wrapped__ automatically and report the original function's signature. Before this, wrapping a function with *args, **kwargs always produced an opaque signature in IDEs and help(), even with @functools.wraps. This is the version where functools.wraps became sufficient for most real-world introspection needs.
PEP 695 introduced a new generic type syntax (def f[T](x: T) -> T: ...), and the __type_params__ attribute was added to WRAPPER_ASSIGNMENTS as part of that implementation. Any code using update_wrapper with a built-in type as the wrapped argument may encounter a TypeError on 3.12+ because type.__type_params__ is a data descriptor, not a plain attribute that setattr can overwrite. This is a CPython issue tracked upstream (cpython #119011).
PEP 649 and PEP 749 (lazy annotation evaluation) introduce __annotate__ as the underlying annotation factory on functions. The official Python 3.14 documentation (docs.python.org, last updated March 30, 2026) still lists __annotations__ in WRAPPER_ASSIGNMENTS — it has not been removed or replaced. On the CPython main branch, the implementation also copies __annotate__ from the wrapped function (CPython issue #124342), but this is an addition alongside the existing attribute handling, not a simple substitution in the tuple. A confirmed side effect on CPython 3.14.0 is that functools.update_wrapper applied to class wrappers around annotated functions can raise a PicklingError during serialization (CPython issue #143067). Code running on 3.13 and earlier is unaffected by any of these changes.
Stacking Decorators: How the __wrapped__ Chain Works in Practice
When multiple decorators are applied to a single function, each decorator that uses functools.wraps or functools.update_wrapper adds its own __wrapped__ link in a chain. Understanding how this chain behaves—and how Python traverses it—is important for debugging decorated stacks and for writing decorators that interact correctly with frameworks.
Consider three decorators applied in sequence:
import functools
import inspect
def decorator_a(func):
@functools.wraps(func)
def wrapper_a(*args, **kwargs):
return func(*args, **kwargs)
return wrapper_a
def decorator_b(func):
@functools.wraps(func)
def wrapper_b(*args, **kwargs):
return func(*args, **kwargs)
return wrapper_b
def decorator_c(func):
@functools.wraps(func)
def wrapper_c(*args, **kwargs):
return func(*args, **kwargs)
return wrapper_c
@decorator_a
@decorator_b
@decorator_c
def target(x: int) -> int:
"""The original target function."""
return x * 2
# Python applies decorators bottom-up:
# target = decorator_a(decorator_b(decorator_c(target)))
# The outermost wrapper is wrapper_a.
# __wrapped__ forms a linked list:
print(target.__name__) # target (copied from original)
print(target.__wrapped__.__name__) # target (wrapper_b, same metadata)
print(target.__wrapped__.__wrapped__.__name__) # target (wrapper_c)
# target.__wrapped__.__wrapped__.__wrapped__ is the original function
# inspect.signature follows the full chain automatically
print(inspect.signature(target)) # (x: int) -> int
# inspect.unwrap walks to the bottom
print(inspect.unwrap(target) is target.__wrapped__.__wrapped__.__wrapped__) # True
Each __wrapped__ link points to the function one layer inward. The chain length equals the number of wraps-compliant decorators applied. inspect.unwrap() always reaches the innermost original function—the one with no __wrapped__ attribute—regardless of how many layers exist.
What happens when a decorator in the middle omits functools.wraps
If one decorator in a stack omits functools.wraps, the chain is silently broken at that point. Everything below the omission becomes invisible to inspect.signature(), inspect.unwrap(), and any framework that relies on __wrapped__:
import functools
import inspect
def uses_wraps(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def omits_wraps(func):
# No functools.wraps — __wrapped__ is not set
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@uses_wraps # outermost
@omits_wraps # middle — chain breaker
@uses_wraps # innermost
def target(x: int) -> int:
"""Original target."""
return x
# The outermost wrapper has __wrapped__ pointing to the middle wrapper.
# The middle wrapper has NO __wrapped__, so the chain stops there.
print(target.__name__) # wrapper (middle wrapper's name, not original)
print(target.__doc__) # None (middle wrapper has no docstring)
# inspect.signature stops following at the middle wrapper
# and sees the generic (*args, **kwargs) signature
print(inspect.signature(target)) # (*args, **kwargs)
# To diagnose, walk the chain manually:
obj = target
depth = 0
while hasattr(obj, '__wrapped__'):
depth += 1
obj = obj.__wrapped__
print(f"Chain depth: {depth}") # 1 (only one link visible)
Every decorator in a stack must use functools.wraps (or functools.update_wrapper) for the full chain to be navigable. A single decorator that omits it silently truncates the chain at that layer. This is a frequent source of confusing behavior in stacked decorator setups: the outermost decorator correctly copies metadata from the next layer inward, but that layer's metadata is already wrong because it failed to copy from the original.
Circular __wrapped__ references and inspect.unwrap()
inspect.unwrap() includes protection against circular __wrapped__ references. If a chain loops back on itself, it raises ValueError instead of looping forever. You would only encounter this in deliberately adversarial or pathological code, but the protection matters in contexts where wrappers are generated programmatically:
import inspect
def f(): pass
def g(): pass
f.__wrapped__ = g
g.__wrapped__ = f # circular chain
try:
inspect.unwrap(f)
except ValueError as e:
print(e) # wrapper loop when unwrapping ...
Three decorators are stacked on a function. The middle decorator omits functools.wraps. What happens to inspect.signature() and inspect.unwrap() when called on the final decorated function?
Diagnostic Toolkit: Auditing a Decorated Function at Runtime
One thing you will not find documented in tutorials is a repeatable pattern for auditing a decorated function to determine exactly which attributes were preserved, whether the __wrapped__ chain is intact, and how many layers deep it runs. The following utility encapsulates everything covered so far into a single callable you can drop into any project for debugging purposes:
import functools
import inspect
def audit_wrapper(fn, *, label=None):
"""
Print a diagnostic report for a decorated function.
Shows: name, qualname, doc, annotations, __wrapped__ chain depth,
signature as seen by inspect, and whether __wrapped__ reaches a
function with no further wrapping.
"""
tag = label or repr(fn)
print(f"\n=== audit_wrapper: {tag} ===")
print(f" __name__ : {getattr(fn, '__name__', '(missing)')}")
print(f" __qualname__ : {getattr(fn, '__qualname__', '(missing)')}")
print(f" __module__ : {getattr(fn, '__module__', '(missing)')}")
doc = getattr(fn, '__doc__', None)
print(f" __doc__ : {doc!r}")
annotations = getattr(fn, '__annotations__', None)
print(f" __annotations__: {annotations}")
type_params = getattr(fn, '__type_params__', '(not set — Python < 3.12 or not generic)')
print(f" __type_params__: {type_params}")
# Walk the __wrapped__ chain
chain = []
obj = fn
seen = set()
while hasattr(obj, '__wrapped__'):
if id(obj) in seen:
chain.append('(circular reference detected)')
break
seen.add(id(obj))
obj = obj.__wrapped__
chain.append(getattr(obj, '__name__', repr(obj)))
print(f" __wrapped__ chain depth : {len(chain)}")
print(f" __wrapped__ chain names : {chain if chain else '(no __wrapped__ set)'}")
# Signature via inspect
try:
sig = inspect.signature(fn)
print(f" inspect.signature() : {sig}")
except (ValueError, TypeError) as exc:
print(f" inspect.signature() : ERROR — {exc}")
# What inspect.unwrap reaches
try:
bottom = inspect.unwrap(fn)
print(f" inspect.unwrap() target : {bottom.__name__!r} (same as fn: {bottom is fn})")
except ValueError as exc:
print(f" inspect.unwrap() : ERROR — {exc}")
print()
# --- Example usage ---
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
def add_prefix(prefix):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
@log_call
@add_prefix("debug")
def compute(x: int, y: int = 0) -> int:
"""Add x and y."""
return x + y
audit_wrapper(compute, label="compute (2 decorators, both use wraps)")
Running this against any decorated function immediately surfaces whether __name__ and __doc__ survived, how deep the chain runs, and whether inspect.signature() resolves to the original signature or a generic one. The chain depth is the fastest way to confirm that every layer in a decorator stack correctly called functools.wraps or functools.update_wrapper.
You can drive audit_wrapper assertions in a test suite by capturing stdout with io.StringIO and contextlib.redirect_stdout, or by converting it into a function that returns a dict of the collected values for assertion. Either way, the pattern gives you a repeatable, structured view of wrapper state that no single built-in call provides on its own.
Customizing Which Attributes Get Copied
Both wraps and update_wrapper accept the same assigned and updated parameters, allowing you to control exactly which attributes are transferred. The defaults are defined as module-level constants:
import functools
# Default assigned attributes (overwritten on wrapper) — Python 3.12 / 3.13 / 3.14
print(functools.WRAPPER_ASSIGNMENTS)
# ('__module__', '__name__', '__qualname__',
# '__annotations__', '__type_params__', '__doc__')
# Per official Python 3.14 docs (last updated March 30, 2026), __annotations__
# remains in WRAPPER_ASSIGNMENTS. The CPython 3.14 main branch also copies
# __annotate__ as part of PEP 649/749 (issue #124342) — this is an addition,
# not a replacement. See the version history section for full details.
# Default updated attributes (merged via dict.update) — all versions
print(functools.WRAPPER_UPDATES)
# ('__dict__',)
You can extend these defaults to copy additional attributes. A common example is adding __defaults__ and __kwdefaults__, which are not copied by default because class wrappers do not have these attributes:
import functools
EXTENDED = functools.WRAPPER_ASSIGNMENTS + (
'__defaults__', '__kwdefaults__',
)
def my_decorator(func):
@functools.wraps(func, assigned=EXTENDED)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name, greeting="Hello"):
"""Greet someone."""
return f"{greeting}, {name}"
print(greet.__defaults__) # ('Hello',)
You can also restrict the copied attributes. If you only want to copy the name and docstring and nothing else, pass a custom tuple:
import functools
def minimal_decorator(func):
@functools.wraps(func, assigned=('__name__', '__doc__'), updated=())
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@minimal_decorator
def example():
"""Example function."""
pass
print(example.__name__) # example
print(example.__doc__) # Example function.
# __module__, __qualname__, __annotations__ are NOT copied
# __dict__ is NOT merged
# __wrapped__ IS still set (it's always added regardless)
Even when you pass a custom assigned tuple that does not include __wrapped__, update_wrapper always sets it. This attribute is hardcoded as Step 3 of the implementation and is not part of the assigned or updated configuration. If you need to prevent __wrapped__ from being set, you must call setattr manually for each attribute instead of using update_wrapper.
Key Takeaways
wrapsisupdate_wrapperwrapped inpartial: The CPython implementation offunctools.wrapsis literallyreturn partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated). They are two interfaces to the same operation.- Use
@functools.wraps(func)for function-based decorators: This is the standard pattern. Place it directly above the inner wrapper function with@syntax. It works for both simple decorators and parameterized decorator factories. - Use
functools.update_wrapper(self, func)for class-based decorators: Call it inside__init__to copy metadata from the original function onto the class instance.@functools.wrapscannot be used with@syntax on a class instance. - Use
functools.update_wrapper(wrapper, func)for after-the-fact fixes: When patching third-party decorators that omitted metadata copying, callupdate_wrapperdirectly on the already-created wrapper. - Both copy the same attributes by default:
__module__,__name__,__qualname__,__annotations__,__type_params__(Python 3.12+), and__doc__are overwritten.__dict__is merged.__wrapped__is always set regardless of the configured parameters. __wrapped__powersinspect.signature(): Since Python 3.5,inspect.signature()follows__wrapped__chains automatically. Without it, IDEs and frameworks see only the generic(*args, **kwargs)signature of the wrapper function. You can stop the chain early by setting__signature__explicitly.__defaults__is not copied by default: If you need direct attribute access to default values (rather than going throughinspect.signature()), extendWRAPPER_ASSIGNMENTSto include__defaults__and__kwdefaults__. Class instances cannot have these attributes, which is why they are excluded from the defaults.- Python 3.12 added
__type_params__: If your code runs on 3.12+ and uses generic functions (PEP 695 syntax),update_wrappernow copies type parameter metadata by default. Usingtypeitself as the wrapped argument can raiseTypeErroron 3.12+ due to the descriptor nature oftype.__type_params__. - Python 3.14 (CPython main branch) adds
__annotate__handling alongside__annotations__: As part of PEP 649 and PEP 749 (lazy annotation evaluation), the CPython 3.14 main branch also copies__annotate__from wrapped functions (CPython issue #124342). The official Python 3.14 documentation (last updated March 30, 2026) still lists__annotations__inWRAPPER_ASSIGNMENTS— it has not been removed. A side effect is that class-based wrappers around annotated functions can raisePicklingErrorduring serialization on CPython 3.14.0 (CPython issue #143067). Code on 3.13 and earlier is unaffected. - One missing
functools.wrapsbreaks the entire stacked chain: When multiple decorators are applied, every decorator in the stack must usefunctools.wraps(orfunctools.update_wrapper) for the full__wrapped__chain to be navigable. A single decorator that omits it silently truncates the chain; layers below the break become invisible toinspect.signature(),inspect.unwrap(), and dispatch frameworks.
The relationship between these two functions is straightforward once you see it: update_wrapper is the engine, and wraps is the steering wheel. One gives you the mechanics; the other gives you a comfortable interface. Pick the one that fits how you are building your decorator, and the result is identical either way.