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.