Function-based Decorators

A function-based decorator is the standard form of Python decoration: a plain function that accepts another function, wraps it with new behavior, and returns the result. Understanding this pattern — including what happens inside the closure, how CPython processes the @ syntax, and what functools.wraps copies under the hood — is essential before reaching for class-based or parameterized variants.

Python treats functions as first-class objects, which means a function can be passed as an argument, stored in a variable, and returned from another function. Decorators are built on exactly this property. When you write @my_decorator above a function definition, Python evaluates it as my_function = my_decorator(my_function) at import time. The original name now points to whatever my_decorator returned — in the typical case, a wrapper function that calls the original internally.

The anatomy of a function decorator

A minimal function-based decorator has three components: the outer decorator function that receives the target function, an inner wrapper function that holds the new behavior, and a return statement that hands back the wrapper. The original function is captured in the closure formed by the outer function's scope, so the wrapper can call it at any point.

How to write a function-based decorator

def my_decorator(func):
    def wrapper():
        print("before the call")
        func()
        print("after the call")
    return wrapper

@my_decorator
def greet():
    print("hello")

greet()
# before the call
# hello
# after the call

The @my_decorator line above greet is syntactic sugar. Python runs greet = my_decorator(greet) immediately when the module loads. After that, greet refers to wrapper, and calling greet() invokes the wrapper body.

Mental Model

Think of a decorator like a sandwich wrapper at a deli. When you order a wrap, the counter worker takes your filling (greet), wraps it in a tortilla (wrapper), and hands you back the wrapped version. From that point on, every time you eat it, you go through the tortilla first — then the filling — then back out through the tortilla. The filling itself never changed; only what surrounds it did.

Predict the Output — Step Through Step 1 of 6
def my_decorator(func):
def wrapper():
print("before"); func(); print("after")
return wrapper
@my_decorator
def greet(): print("hello")
greet()
Click Next Step to walk through what Python does, line by line.
Note

The decorator runs once at definition time, not each time the decorated function is called. The wrapper it returns is what runs on every call. This distinction matters when the decorator performs expensive setup work.

Returning values from the wrapper

The example above works for functions that return nothing. If the target function returns a value, the wrapper must capture and pass it back to the caller. A wrapper that does not return anything implicitly returns None, silently discarding the original return value.

def log_call(func):
    def wrapper():
        print(f"calling {func.__name__}")
        result = func()
        print(f"{func.__name__} returned {result!r}")
        return result   # forward the return value
    return wrapper

@log_call
def get_version():
    return "3.12"

v = get_version()
# calling get_version
# get_version returned '3.12'
print(v)  # 3.12
Pro Tip

Always write return result at the end of your wrapper. Even if the function you are decorating today returns None, future changes may add a return value. A wrapper that forwards the result is correct in both cases.

Python Pop Quiz Decorator basics

You apply @my_decorator to a function named send. The decorator does not use functools.wraps. Immediately after the module loads, what does send.__name__ return?

How the closure actually works

The wrapper function returned by a decorator is a closure: a function object bundled together with references to variables from the enclosing scope that it captured at definition time. In the decorator pattern, the captured variable is almost always func — the original function passed in as an argument. Python stores these captured references in an internal structure called cell objects, accessible via the function's __closure__ attribute.

import functools

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

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

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

print(add.__closure__[0].cell_contents)
# <function add at 0x...>  — the original unwrapped function

Each cell in __closure__ holds a reference to one captured variable. Calling cell_contents on a cell reveals what it holds. For a decorator, that is the original function — which is exactly what __wrapped__ also exposes, though via a different mechanism. The cell objects are created when the outer decorator function executes, which is why decoration happens at import time rather than at call time.

This distinction has a practical consequence: if your decorator does something expensive — reading a config file, compiling a regex, importing a module — that cost is paid once when the decorated function is defined, not once per call. Placing that work outside the wrapper is a deliberate optimization you can exploit.

import re
import functools

# regex is compiled ONCE at decoration time, not per call
def validate_email(func):
    _pattern = re.compile(r'^[^@]+@[^@]+\.[^@]+$')  # runs once
    @functools.wraps(func)
    def wrapper(email, *args, **kwargs):
        if not _pattern.match(email):
            raise ValueError(f"Invalid email: {email!r}")
        return func(email, *args, **kwargs)
    return wrapper

@validate_email
def send_message(email, body):
    print(f"Sending to {email}: {body}")

The _pattern regex is compiled when the module loads and captured in the closure. Every subsequent call to send_message reuses the same compiled pattern object without re-running re.compile.

The late-binding gotcha in decorator loops

Closures in Python capture variable references, not values. This distinction is invisible in the standard decorator pattern because the captured variable (func) is a function argument — its value is fixed at the moment the decorator is called. The problem surfaces when you build multiple decorators inside a loop using a loop variable as the captured value.

# BUG: all three wrappers print "c" because they share the same cell
validators = []
for label in ['a', 'b', 'c']:
    def check(func):
        def wrapper(*args, **kwargs):
            print(label)   # captures the cell, not the value
            return func(*args, **kwargs)
        return wrapper
    validators.append(check)

# FIX: force early binding with a default argument
validators = []
for label in ['a', 'b', 'c']:
    def check(func, _label=label):   # _label captures the value now
        def wrapper(*args, **kwargs):
            print(_label)
            return func(*args, **kwargs)
        return wrapper
    validators.append(check)

The default-argument trick works because default values are evaluated at function definition time, creating a new binding for each iteration. This is a niche scenario in decorator authoring, but it surfaces frequently enough in frameworks and plugin registries to be worth knowing before you encounter it.

Python Pop Quiz Closures and cell objects

A decorator compiles a regex with re.compile() inside the wrapper function body. How many times does the compile step run if the decorated function is called 1,000 times?

Forwarding arguments and preserving metadata

The simple wrapper above only works for functions that accept no arguments. A general-purpose decorator must accept any combination of positional and keyword arguments and forward them unchanged. The Python convention is *args, **kwargs in the wrapper signature, forwarded directly to the original function call.

def timer(func):
    import time

    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} ran in {elapsed:.6f}s")
        return result

    return wrapper

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

total = add(3, 7)
# add ran in 0.000001s
print(total)  # 10

The wrapper here accepts any arguments, passes them through with func(*args, **kwargs), and returns whatever the original function returns. This pattern makes the decorator signature-agnostic: it will work on any function without modification.

Preserving function metadata with functools.wraps

When Python replaces the original function with the wrapper, the wrapper's own __name__, __doc__, __annotations__, and other dunder attributes replace the original ones. This breaks introspection tools, documentation generators, and anything that reads func.__name__ at runtime.

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

@naive_decorator
def process():
    """Process data."""
    pass

print(process.__name__)  # wrapper   (wrong)
print(process.__doc__)   # None      (lost)

functools.wraps fixes this. Applying it as a decorator on the wrapper function copies the original's metadata onto the wrapper, so callers and tools see the correct name and docstring.

import functools

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

@robust_decorator
def process():
    """Process data."""
    pass

print(process.__name__)  # process   (correct)
print(process.__doc__)   # Process data.
Warning

Omitting @functools.wraps will cause failures in any code that inspects function names at runtime, including logging frameworks that use func.__name__, test runners that report by function name, and Python's own help() built-in.

Comparison: with and without functools.wraps

Attribute Without functools.wraps With functools.wraps
Without functools.wraps
"wrapper"
With functools.wraps
Original function name
Without functools.wraps
None (lost)
With functools.wraps
Original docstring
Without functools.wraps
Wrapper's annotations (empty)
With functools.wraps
Original type annotations
Without functools.wraps
Not set
With functools.wraps
Reference to original function

The __wrapped__ attribute added by functools.wraps is particularly useful in testing: it lets you access the unwrapped original function directly via decorated_func.__wrapped__, so unit tests can call the core logic without triggering the decorator.

What functools.wraps copies — and what it does not

functools.wraps is a thin wrapper around functools.update_wrapper, which reads from two constants: WRAPPER_ASSIGNMENTS and WRAPPER_UPDATES. In Python 3.12, __type_params__ was added to WRAPPER_ASSIGNMENTS to support PEP 695 type parameter syntax on generic functions. The full set copied by assignment is __module__, __name__, __qualname__, __doc__, __annotations__, and __type_params__ (3.12+). The wrapper's __dict__ is updated (not replaced) with the wrapped function's __dict__. The complete list is documented in the functools.update_wrapper reference.

The version history matters here. The __wrapped__ attribute was added in Python 3.2 — the same release that also began copying __annotations__ by default and stopped raising AttributeError when a wrapped attribute is missing from the source. Before Python 3.2, decorating a function stripped annotations silently. Before 3.4, if the wrapped function itself defined a __wrapped__ attribute, the chain could break; that was fixed so the attribute always points to the immediate wrapped function regardless. Understanding these version boundaries matters in codebases that still need to support older Python environments.

What most resources omit: functools.wraps accepts its own assigned and updated parameters, letting you customize exactly what gets copied. If you need to transfer default argument values as well — for example, to keep them visible to an IDE — you can pass an extended tuple:

import functools

# The standard set (Python 3.12+)
# ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__',
#  '__type_params__')

# Custom extended set that also preserves defaults and kwdefaults
MORE_ASSIGNMENTS = functools.WRAPPER_ASSIGNMENTS + ('__defaults__', '__kwdefaults__')

def preserving_decorator(func):
    @functools.wraps(func, assigned=MORE_ASSIGNMENTS)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

print(greet.__defaults__)   # ('world',)  — now visible directly on the wrapper

This pattern is rare but surfaces in plugin systems or introspection-heavy frameworks where the wrapper needs to appear identical to the original function down to its default values. Note that inspect.signature() follows __wrapped__ automatically since Python 3.3, so for most real-world purposes the standard @functools.wraps(func) is sufficient and copying __defaults__ manually is only needed when something reads the attribute directly rather than going through inspect.

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 = "world") -> str:
    """Return a greeting string."""
    return f"Hello, {name}"

# __defaults__ is NOT on the wrapper itself
print(greet.__defaults__)        # None  (wrapper has *args/**kwargs)
print(greet.__wrapped__.__defaults__)  # ('world',)  — original defaults

# inspect.signature follows __wrapped__
print(inspect.signature(greet))  # (name: str = 'world') -> str

# unwrap the full chain
print(inspect.unwrap(greet))     # <function greet at 0x...>

inspect.unwrap() follows the __wrapped__ chain iteratively until it reaches a function with no further __wrapped__ attribute, returning the innermost original callable. This is useful when multiple decorators are stacked — you can reach the original function without manually walking .__wrapped__.__wrapped__. What few resources mention: inspect.unwrap() accepts a stop callable argument. If provided, it is called at each step of the chain, and unwrapping stops early when it returns a truthy value. This lets you halt at a specific intermediate wrapper rather than always going all the way to the bare function — useful in testing scenarios where you want to skip one decorator layer but not all of them.

"Without the decorator factory, the function name and docstring would be lost."
— Python documentation, functools.wraps (docs.python.org)

Stateful decorators and nonlocal

A decorator wrapper ordinarily has no persistent state — each call is independent. When you need the wrapper to accumulate data across calls (a call counter, a cache, a rate limiter), you have two approaches: use a mutable container in the closure (a list or dict), or declare the counter variable with nonlocal. The distinction matters because Python's LEGB scope rules treat any variable that is assigned inside a function as local unless explicitly declared otherwise.

import functools

# Approach 1: mutable container (works in Python 2 and 3)
def count_calls_v1(func):
    calls = [0]   # list used as a mutable cell
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        calls[0] += 1
        print(f"call #{calls[0]}")
        return func(*args, **kwargs)
    return wrapper

# Approach 2: nonlocal (Python 3 only, cleaner)
def count_calls_v2(func):
    count = 0
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count          # tell Python: count lives in the enclosing scope
        count += 1
        print(f"call #{count}")
        return func(*args, **kwargs)
    return wrapper

@count_calls_v2
def process(x):
    return x * 2

process(1)   # call #1
process(2)   # call #2
process(3)   # call #3

Without nonlocal count, the line count += 1 raises UnboundLocalError. Python sees the assignment and treats count as a local variable for the entire wrapper body. When it then tries to evaluate the right side (count + 1), the local variable has not been assigned yet. nonlocal tells Python to look in the enclosing scope instead, resolving to the count = 0 defined in the decorator function.

Pro Tip

State attached to a closure-based wrapper is per decorated function, not global. Each call to the decorator creates a new closure with its own independent counter. If you need shared state across multiple decorated functions, use a class-based decorator or a module-level dict keyed by function identity.

Spot the Bug Something is broken in this stateful decorator

The decorator below is supposed to count how many times process is called and print the count on each call. Running it raises an error. Which line is the problem, and why?

import functools   def count_calls(func): count = 0 @functools.wraps(func) def wrapper(*args, **kwargs): count += 1 <-- error here print(f"call #{count}") return func(*args, **kwargs) return wrapper   @count_calls def process(x): return x * 2   process(5) # raises UnboundLocalError
Python Pop Quiz Stateful decorators

Two different functions are decorated with the same @count_calls decorator. After calling each one three times, what does the counter on the first function show?

Stacking decorators and execution order

Python allows multiple decorators to be applied to a single function by listing them one per line above the definition. The decorator closest to the def line wraps the function first. Each decorator above it wraps the result of the one below. Reading from bottom to top gives the wrapping order; reading from top to bottom gives the call order at runtime.

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("bold: before")
        result = func(*args, **kwargs)
        print("bold: after")
        return result
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("italic: before")
        result = func(*args, **kwargs)
        print("italic: after")
        return result
    return wrapper

@bold
@italic
def render():
    print("render")

render()
# bold: before
# italic: before
# render
# italic: after
# bold: after

At definition time Python evaluates this as render = bold(italic(render)). The italic wrapper is innermost, so it runs next to the actual render call. The bold wrapper is outermost, so it runs first and last.

bold wrapper italic wrapper render() call return execution order: bold before → italic before → render → italic after → bold after
tap to enlarge
Stacked decorator execution order — the outermost decorator runs first on entry and last on exit.

A practical stacking example: logging and timing together

import functools
import time

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

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

@log_call
@timer
def fetch_data(url):
    # simulate I/O
    time.sleep(0.05)
    return {"url": url, "status": 200}

response = fetch_data("https://example.com")
# [LOG] calling fetch_data
# [TIMER] fetch_data: 0.0501s
print(response)  # {'url': 'https://example.com', 'status': 200}

Notice that func.__name__ inside both decorators prints fetch_data rather than wrapper, because each decorator's wrapper carries @functools.wraps(func). Without it, the timer wrapper would be labelled wrapper by the time log_call wraps it, and the log line would read [LOG] calling wrapper.

Note

When you need a decorator that accepts its own configuration arguments — for example, @retry(times=3) — you add another layer of nesting: a factory function that accepts the arguments and returns the actual decorator. That pattern is covered separately in the parameterized decorators article.

Quiz Check Your Understanding
Question 1 of 3
Python Pop Quiz Stacking order

Given @bold above @italic above def render, which wrapper runs first when you call render()?

Why the @ syntax looks like it does

The @ decorator syntax was not in Python from the start. Before it was introduced in Python 2.4 (released October 2004 via PEP 318), the only way to decorate a function was the explicit reassignment form:

# Python 2.2 / 2.3 style — before @ syntax
def my_function(x):
    return x * 2

my_function = some_decorator(my_function)   # easy to forget; easy to get wrong name

This was error-prone for two reasons: the function name had to be retyped, and the decoration line sat far from the def statement in long functions, making it easy to miss. The community debated the syntax extensively — PEP 318 documents hundreds of proposed alternatives including decorate blocks, pipe syntax, and prefix keywords. The discussion ran on python-dev intermittently from February 2002 through July 2004. Guido van Rossum took a shortlist of proposals to EuroPython 2004 in Gothenburg, where the decision was finalized. The syntax that won — @decorator immediately before the def line — drew its @ character from Java, where it had already been used in Javadoc comments and then adopted for Java annotations. PEP 318 acknowledges this lineage directly. Guido's final choice was guided by readability instinct rather than any formal evaluation: the statement is limited in what it can accept — arbitrary expressions do not work — and PEP 318 records that Guido preferred this because of a gut feeling.

Barry Warsaw named the result the "pie-decorator" syntax — partly because @ visually resembles a pie, and partly as a nod to the Pie-thon Parrot shootout running concurrently at the time. That name did not stick, but the syntax did. PEP 3129 extended decorator support to class definitions using the same @ syntax, targeting Python 2.6.

"Guido preferred this because of a gut feeling."
PEP 318, on the final @ decorator syntax choice (peps.python.org)

What the bytecode actually looks like

The @ syntax is syntactic sugar, but understanding exactly what CPython emits helps explain the runtime behavior. You can inspect the bytecode with the dis module:

import dis

def my_decorator(func):
    return func

@my_decorator
def greet():
    pass

dis.dis(greet)

For a decorated function definition, CPython evaluates the decorator expression first, then creates the function object with MAKE_FUNCTION, then calls the decorator with CALL (or CALL_FUNCTION in older versions), and finally stores the result with STORE_NAME. The decorated name is never bound to the raw function — only to the decorator's return value. This is the bytecode-level confirmation that @my_decorator above def greet is completely equivalent to greet = my_decorator(greet).

For stacked decorators, the decorators are loaded onto the stack from bottom to top, and then applied top-down as each CALL instruction executes. This is why the decorator closest to def wraps first — it is called first — even though it appears lowest in the source.

Pro Tip

Run import dis; dis.dis(your_module) on any module to see all decorator calls in their bytecode form. It is one of the fastest ways to confirm execution order when a stacked decorator combination produces unexpected behavior.

Using functools.wraps with ParamSpec (Python 3.10+)

One limitation that surfaces in typed Python codebases is that functools.wraps preserves the function's metadata at runtime but loses type information at the type-checker level — specifically, the wrapper's signature shows as (*args: Any, **kwargs: Any) rather than matching the original. PEP 612 (Python 3.10) introduced ParamSpec to solve this. Combined with TypeVar, it lets you express that a decorator preserves both the argument types and return type of the wrapped function.

from __future__ import annotations
import functools
from typing import Callable, TypeVar, ParamSpec

P = ParamSpec("P")   # captures the parameter signature
R = TypeVar("R")     # captures the return type

def log_call(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"[LOG] calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_call
def add(x: int, y: int) -> int:
    return x + y

# Type checker now knows add(x: int, y: int) -> int
# rather than add(*args: Any, **kwargs: Any) -> Any
result: int = add(1, 2)   # passes type checking

The Callable[P, R] annotation on both the decorator parameter and its return type tells type checkers (mypy, Pyright) that the wrapper has exactly the same call signature as the wrapped function. This does not affect runtime behavior — functools.wraps still does the metadata copy work — but it eliminates false type errors in callers and preserves IDE autocompletion for decorated functions. This pattern is worth knowing in any codebase that takes static typing seriously, and it is absent from most decorator introductions.

Key Takeaways

  1. Three-part structure: Every function-based decorator consists of an outer function that receives the target, an inner wrapper function that adds behavior, and a return wrapper statement that hands back the replacement callable.
  2. Always use *args and **kwargs: Define the wrapper with *args, **kwargs and forward them with func(*args, **kwargs) so the decorator works with any function signature and always returns the original result.
  3. Always apply functools.wraps: Decorate every wrapper with @functools.wraps(func) to preserve the original function's __name__, __doc__, __annotations__, and to set __wrapped__ for testing access. Note that __defaults__ is intentionally not copied — access it via func.__wrapped__.__defaults__ if needed.
  4. Closures capture references, not values: The wrapper holds a reference to the cell object containing func. Expensive one-time setup (compiling a regex, opening a config) belongs outside the wrapper so it runs once at decoration time.
  5. Use nonlocal for stateful wrappers: If the wrapper needs to mutate an enclosing variable (a call counter, a cached result), declare it nonlocal or use a mutable container. Assigning without nonlocal raises UnboundLocalError.
  6. Stacking order is bottom-up at definition, top-down at call time: The decorator immediately above the def line wraps first; the topmost decorator's wrapper runs first during a call and last on return. Use dis.dis() to verify bytecode order when stacking produces unexpected output.
  7. @ syntax arrived in Python 2.4: Before PEP 318, decoration required manual reassignment (f = deco(f)). The current syntax was chosen for readability; CPython compiles it to the same bytecode as the explicit form.
  8. Use ParamSpec for type-safe decorators (Python 3.10+): Annotating the decorator as Callable[P, R] -> Callable[P, R] and using P.args / P.kwargs in the wrapper tells type checkers the wrapper preserves the original signature. Without this, type checkers report the wrapper as (*args: Any, **kwargs: Any), hiding errors at call sites.

Function-based decorators cover the large majority of real-world use cases. Once the three-part pattern, closure mechanics, and argument-forwarding idiom are second nature, more advanced forms — parameterized decorators, class-based decorators, and descriptor-based decoration — are natural extensions of the same underlying model.

Sources

Frequently Asked Questions

What is a function-based decorator in Python?

A function-based decorator is a callable that accepts a function as its argument, defines an inner wrapper function that adds behavior before or after the original call, and returns the wrapper. Applying it with the @ syntax replaces the original function with the decorated version.

Why should I use functools.wraps inside a decorator?

Without functools.wraps, the wrapper function overwrites the original function's __name__, __qualname__, __module__, __doc__, __annotations__, and other metadata. Applying @functools.wraps(func) to the wrapper copies those attributes from the original function, and also sets __wrapped__ to point at the original — keeping introspection tools, documentation generators, and test utilities accurate.

How do I pass arguments through a decorator?

Define the wrapper with *args and **kwargs and forward them to the original function call: result = func(*args, **kwargs). This ensures the decorator works with any function signature without modification.

Can I stack multiple decorators on one function?

Yes. Python applies stacked decorators from bottom to top, so the decorator closest to the def line wraps the function first. Each subsequent decorator wraps the result of the one below it.

What is the difference between a decorator and a higher-order function?

A higher-order function is any function that accepts or returns another function. A decorator is a specific pattern that uses a higher-order function to wrap and replace an existing callable, typically applied with the @ syntax at definition time.

How does a Python decorator closure work?

A decorator's wrapper function is a closure: a function object that retains a reference to the original function through a cell object stored in its __closure__ attribute. When the decorator runs at module load time, CPython creates these cell objects to keep the original function alive so the wrapper can call it on every subsequent invocation.

How do I add state to a Python decorator?

Declare a counter or cache variable in the decorator function body, then use the nonlocal keyword inside the wrapper to modify it. Without nonlocal, Python treats any assigned name as a new local variable and raises UnboundLocalError when the wrapper tries to read it before assigning.

Why does functools.wraps not copy __defaults__?

The wrapper function uses *args and **kwargs as its signature, so copying __defaults__ from the original would create a mismatch. Access original default values via decorated_func.__wrapped__.__defaults__. As of Python 3.3, inspect.signature() follows __wrapped__ automatically and reports the original signature, so for most real-world purposes the standard @functools.wraps(func) is sufficient.

How do I write a type-safe decorator in Python using ParamSpec?

Use ParamSpec (P = ParamSpec("P")) and TypeVar (R = TypeVar("R")) from the typing module, available since Python 3.10 via PEP 612. Annotate the decorator parameter and return type as Callable[P, R], and type the wrapper's *args and **kwargs as P.args and P.kwargs. This tells type checkers that the wrapper preserves the wrapped function's full call signature and return type.