Python – Decorators

July 26, 20264 min readUpdated 8/20/2026

A decorator wraps a function in another function to add behaviour without editing it. They look like magic until you see the one fact underneath, so this post builds one from scratch rather than starting with the @.

Functions are objects

Everything a decorator does follows from this. A function can be assigned to a name, passed as an argument, and returned from another function:

def greet(name):
    return f"Hello, {name}"

say = greet                       # a second name for the same function
print(say("Alice"))               # Output: Hello, Alice

def call_twice(fn, value):
    return fn(fn(value))

print(call_twice(str.upper, "ab"))   # Output: AB

No brackets after greet on the third line. greet is the function; greet() is the result of calling it. Confusing the two is the single most common mistake when starting with decorators.

A function that returns a function

def with_logging(fn):
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__}")
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} returned {result}")
        return result
    return wrapper

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

add = with_logging(add)
print(add(2, 3))

# Output: calling add
# Output: add returned 5
# Output: 5

with_logging takes a function and gives back a new one that does something before and after calling the original. *args, **kwargs is doing real work here — the wrapper has to accept whatever the wrapped function accepts, and it does not know what that is.

That is a decorator. The rest is syntax.

The @ syntax

def with_logging(fn):
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

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

print(add(2, 3))

# Output: calling add
# Output: 5

@with_logging above the def means exactly add = with_logging(add) underneath it. Nothing else. It runs once, when the module is imported, not on each call.

functools.wraps is not optional

The wrapper replaces the original, and it brings its own name and docstring with it:

def broken(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@broken
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)     # Output: wrapper
print(add.__doc__)      # Output: None

The function is now called wrapper and has lost its documentation. Every traceback, every debugger, every help() call will say wrapper. Fix it with one line:

import functools

def logged(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)     # Output: add
print(add.__doc__)      # Output: Add two numbers.

@functools.wraps(fn) copies the name, docstring and metadata across. Put it on every decorator you write, without thinking about whether this one needs it.

A decorator that takes arguments

This needs one more layer, and the extra nesting is why it looks worse than it is:

import functools

def repeat(times):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = fn(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("ping")
    return "done"

print(ping())

# Output: ping
# Output: ping
# Output: ping
# Output: done

Three levels, each with one job: repeat takes the arguments, decorator takes the function, wrapper takes the call. The reason for the extra layer is that @repeat(times=3) calls repeat first, and whatever comes back is what decorates the function.

The ones you already use

import functools

class Account:
    def __init__(self, balance):
        self.balance = balance

    @property
    def formatted(self):
        return f"${self.balance:,.2f}"

    @staticmethod
    def fee():
        return 2.50

@functools.cache
def slow_square(n):
    return n * n

account = Account(1250)
print(account.formatted)      # Output: $1,250.00
print(Account.fee())          # Output: 2.5
print(slow_square(12))        # Output: 144

@property makes a method readable as an attribute — note account.formatted with no brackets. @staticmethod is a function that lives in the class but needs no instance. @functools.cache remembers results so a repeated call with the same arguments skips the work entirely, which is a one-line fix for a slow pure function.

Frameworks lean on the same mechanism: @app.route in Flask, @pytest.fixture in pytest, @dataclass in dataclasses.

A useful one: timing

Enough theory — here is a decorator worth keeping. It reports how long a function took, and it works on any function without touching a line of it:

import functools
import time

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        started = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - started
            print(f"{fn.__name__} took {elapsed:.4f}s")
    return wrapper

@timed
def total(n):
    return sum(range(n))

result = total(100)
print(result)      # Output: 4950

Two details make this the version to copy. time.perf_counter() rather than time.time() — it is monotonic, so a clock adjustment mid-measurement cannot produce a negative duration. And the try/finally, so the timing still prints when the wrapped function raises. A timing decorator that goes silent on the slow call that failed is worse than none.

The output line is deliberately not asserted above, because the elapsed time differs on every run — which is itself a useful reminder that anything time-dependent needs care in a test.

Order matters

def outer(fn):
    def w(*a, **k):
        print("outer")
        return fn(*a, **k)
    return w

def inner(fn):
    def w(*a, **k):
        print("inner")
        return fn(*a, **k)
    return w

@outer
@inner
def job():
    print("job")

job()

# Output: outer
# Output: inner
# Output: job

Decorators are applied bottom-up — job = outer(inner(job)) — and therefore run top-down. When one of them authenticates and another logs, that order is the difference between logging attempted access and logging granted access.

When to write one

The test is whether the behaviour is genuinely orthogonal to what the function does: logging, timing, caching, retrying, checking permissions. Those are worth a decorator because they apply identically to twenty functions.

Anything specific to one function belongs in that function, where a reader will find it. A decorator moves code somewhere the call site does not show, and that is a real cost — spend it on cross-cutting concerns only.

Next: Class.