Decorators

Updated

September 8, 2026

Decorators

A decorator is a function that takes a function and returns a replacement. The boring default is a thin wrapper that logs, times, or checks something, then calls the original. Always apply functools.wraps so the wrapper keeps the original name and docstring.

Mental model

@log
def open_table(n):
    ...

means “define open_table, then set open_table = log(open_table).” The replacement is what callers invoke.

The wrapper should accept *args, **kwargs unless you know the exact signature. It should return whatever the original returns (or raise whatever it raises).

functools.wraps(fn) copies fn’s __name__, __doc__, and a few other attributes onto the wrapper. Without it, traces and help talk about wrapper.

Stacked decorators apply bottom first: @a then @b on f is f = a(b(f)).

Worked examples

Case 1: A function that wraps a function

Save as log_open.py. log prints the name and the arguments, then calls the original.

# log_open.py
def log(fn):
    def wrapper(*args, **kwargs):
        print(f"call {fn.__name__}{args}")
        return fn(*args, **kwargs)

    return wrapper


@log
def open_table(n):
    return f"opened table {n}"


def main():
    print(open_table(12))


if __name__ == "__main__":
    main()

Run:

uv run python log_open.py

Output:

call open_table(12,)
opened table 12

That is the whole trick. No metaclass. No import magic.

Case 2: functools.wraps

Save as wrapped_open.py. After @wraps(fn), the public name is still open_table.

# wrapped_open.py
from functools import wraps


def log(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"call {fn.__name__}")
        return fn(*args, **kwargs)

    return wrapper


@log
def open_table(n):
    """Open one table and return a label."""
    return f"opened table {n}"


def main():
    print(open_table.__name__)
    print(open_table.__doc__)
    print(open_table(4))


if __name__ == "__main__":
    main()

Run:

uv run python wrapped_open.py

Output:

open_table
Open one table and return a label.
call open_table
opened table 4

Keep @wraps even on internal tools. Tests and traces will thank you.

Case 3: Stacked decorators

Save as stacked.py. count_calls runs first (closer to the function). log wraps that result. Both use wraps.

# stacked.py
from functools import wraps


def log(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"log {fn.__name__}")
        return fn(*args, **kwargs)

    return wrapper


def count_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        print(f"count {wrapper.calls}")
        return fn(*args, **kwargs)

    wrapper.calls = 0
    return wrapper


@log
@count_calls
def ping():
    return "pong"


def main():
    print(ping())
    print(ping())
    print("name", ping.__name__)


if __name__ == "__main__":
    main()

Run:

uv run python stacked.py

Output:

log ping
count 1
pong
log ping
count 2
pong
name ping

Read the source from the bottom: ping is counted, then logged. Swap the two @ lines if you want the other order, and print again.

The trap

Without wraps, the object people import is named wrapper. help, traces, and a registry of __name__ all lie.

Save as bare_wrapper.py:

# bare_wrapper.py
def log(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)

    return wrapper


@log
def open_table(n):
    """Open one table and return a label."""
    return f"opened table {n}"


def main():
    print(open_table.__name__)
    print(open_table.__doc__)
    print(open_table(12))


if __name__ == "__main__":
    main()

Run:

uv run python bare_wrapper.py

Output:

wrapper
None
opened table 12

The return value still works. The metadata does not. Copy the decorator from wrapped_open.py@wraps(fn) on the inner function — and __name__ becomes open_table again.

The boring rule

  • A decorator is def deco(fn): plus an inner wrapper plus return wrapper.
  • Put @wraps(fn) on every wrapper you ship.
  • Forward with *args, **kwargs and return the original result.
  • Stacked @ lines: bottom decorator runs first.
  • Do not decorate a function to look clever. Decorate when many functions share one extra behaviour (log, retry, require a role).

Try this

  1. In log_open.py, make open_table take n and label, and log both. Call open_table(12, label="patio").
  2. In wrapped_open.py, print open_table.__wrapped__(12) and confirm it skips the log line.
  3. In stacked.py, swap @log and @count_calls. Run it. Note which line prints first.
  4. Fix bare_wrapper.py with functools.wraps so __name__ is open_table and __doc__ is the docstring.