Decorators
Wrap any function with logging, retry, caching, or auth — without touching its code. The @ syntax, demystified.
What you'll learn
- Functions are objects — pass them, return them, store them
- The @ syntax, and what it literally expands to
- Why you need functools.wraps, and what breaks without it
- Parameterized decorators, stacking order, and real-world uses
Before you start
A decorator sounds advanced and is, at heart, almost embarrassingly simple: it is a function that takes a function and returns a new function. That is the whole definition. The @ symbol is just a tidy way to apply one. Once that clicks, a long list of things that looked like separate magic — @retry, @app.get("/users"), @property, @dataclass — turn out to be the very same trick, and you can read framework code that used to look opaque.
Functions are objects
The idea decorators rest on is that a function in Python is an ordinary object: you can assign it to a variable, drop it in a list, pass it to another function, and return it from one. Let us establish that plainly before any @ appears:
def greet(name):
return f"Hello, {name}!"
# Assign a function to another name.
say = greet
print(say("Aarav"))
# Put functions in a list and call each.
funcs = [greet, str.upper, len]
for f in funcs:
print(f("Priya"))
# Take a function in, return a new function out.
def shout_wrapper(fn):
def wrapper(name):
return fn(name).upper()
return wrapper
loud_greet = shout_wrapper(greet)
print(loud_greet("Diego"))
Hello, Aarav!
Hello, Priya!
PRIYA
5
HELLO, DIEGO!
Look at shout_wrapper: it accepts a function and returns a new function that calls the original and then upper-cases the result. That is already a decorator — a higher-order function, one that takes or returns another function. Everything that follows is just nicer syntax for applying it.
The @ syntax
Here is the same shout_wrapper, now applied with @:
def shout(fn):
def wrapper(name):
return fn(name).upper()
return wrapper
@shout
def greet(name):
return f"Hello, {name}!"
# @shout above greet is EXACTLY: greet = shout(greet)
print(greet("Sofia"))
HELLO, SOFIA!
That is the one equivalence to memorise: @shout written above a function is identical to greet = shout(greet) written below it. It is pure sugar — but it puts the intent right at the definition, where a reader will see it.
A first real decorator — logging every call
The pattern that makes decorators general is *args, **kwargs. By forwarding whatever arguments arrive, one decorator works on any function. Here is a call-logger that announces what goes in and what comes out:
import functools
def logged(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(f" -> {fn.__name__}{args}")
result = fn(*args, **kwargs)
print(f" <- {fn.__name__} returned {result!r}")
return result
return wrapper
@logged
def square_sum(n):
return sum(i * i for i in range(n))
@logged
def make_user(user_id, name):
return {"id": user_id, "name": name}
square_sum(5)
make_user(42, "Aarav")
-> square_sum(5,)
<- square_sum returned 30
-> make_user(42, 'Aarav')
<- make_user returned {'id': 42, 'name': 'Aarav'}
Because wrapper(*args, **kwargs) accepts and forwards anything, the same @logged decorated both a one-argument and a two-argument function without changing a line. Swap the body for a timer, a retry loop, or an auth check and you have logging, resilience, and security — all without touching the functions themselves. That separation is the entire appeal.
The functools.wraps detail
You may have noticed @functools.wraps(fn) in that wrapper, and it is not optional decoration. Without it, the decorated function forgets its own identity — its name, docstring, and signature all become the wrapper’s — and that quietly breaks debuggers, IDE help, and tools like FastAPI that build their schemas by introspection:
import functools
def bad(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def good(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@bad
def add(a, b):
"""Add two numbers."""
return a + b
print("WITHOUT wraps:")
print(" name:", add.__name__)
print(" doc :", add.__doc__)
@good
def subtract(a, b):
"""Subtract b from a."""
return a - b
print("WITH wraps:")
print(" name:", subtract.__name__)
print(" doc :", subtract.__doc__)
WITHOUT wraps:
name: wrapper
doc : None
WITH wraps:
name: subtract
doc : Subtract b from a.
The contrast is stark: without @wraps, add reports its name as wrapper and loses its docstring entirely; with it, subtract keeps both.
Parameterized decorators — the three-layer pattern
What about a decorator that takes its own arguments, like @retry(times=3)? That needs three nested functions: the outer accepts the arguments, the middle accepts the function, and the inner does the work at call time. To keep the demo exact rather than random, our flaky call is rigged to fail twice and then succeed:
import functools, time
def retry(times=3, delay=0.05):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_err = None
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
last_err = e
print(f" attempt {attempt} failed: {e}")
time.sleep(delay)
raise last_err
return wrapper
return decorator
# A call that fails its first two attempts, then succeeds on the third.
state = {"calls": 0}
@retry(times=4, delay=0.05)
def flaky_api_call():
state["calls"] += 1
if state["calls"] < 3:
raise ConnectionError("network blip")
return {"data": "ok"}
print("result:", flaky_api_call())
attempt 1 failed: network blip
attempt 2 failed: network blip
result: {'data': 'ok'}
The mental model is the key: @retry(times=4) first calls retry(times=4), which returns decorator, which is then applied to the function. So retry is not itself the decorator — it is a decorator factory, and the extra layer exists precisely to capture the arguments.
A caching decorator
Caching is the classic decorator, and writing one shows how little there is to it — a dictionary keyed on the arguments:
import functools
def memo(fn):
cache = {}
@functools.wraps(fn)
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
@memo
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(50))
12586269025
That fib(50) returns instantly. Without the cache it would fan out into roughly a billion redundant recursive calls; with it, each n is computed once and remembered. In real code you would not hand-roll this — the standard library’s functools.lru_cache (and functools.cache, from Python 3.9) does it better — but understanding the mechanism is exactly what lets you read how those work.
Stacking decorators
import functools
def loud(fn):
@functools.wraps(fn)
def w(*a, **k):
print(f" calling {fn.__name__}")
return fn(*a, **k)
return w
def upper_result(fn):
@functools.wraps(fn)
def w(*a, **k):
return fn(*a, **k).upper()
return w
@loud
@upper_result
def greet(name):
return f"hello, {name}"
print(greet("aarav"))
calling greet
HELLO, AARAV
Stacking applies bottom-up at definition time — the decorator closest to def wraps first — so greet becomes loud(upper_result(greet)). At call time the layers run outside-in: loud’s wrapper runs first (printing the line), then hands off to upper_result’s wrapper (which upper-cases the result). The picture below is the way to hold it in your head — a decorated function is an onion, and a call passes inward through each layer and the result unwinds back out:
In one breath
- A decorator is a function that takes a function and returns a new one;
@dmeansf = d(f). - Make a wrapper general with
*args, **kwargsso it forwards any call. - Always add
@functools.wraps(fn), or the wrapped function loses its name, docstring, and signature. - A decorator that takes arguments needs three layers — it is a decorator factory.
- Stacked decorators apply bottom-up; a call runs them outside-in, like an onion.
Practice
Quick check
What’s next
Decorators wrap functions. Context managers wrap blocks of code — the same idea of “do something before and after,” with a different syntax built around with.
Questions about this lesson
What is a Python decorator, in simple terms?
A decorator is a function that wraps another function to add behavior before or after it runs, without changing the original function's code. You apply it with the `@decorator` syntax above a definition. Common uses are logging, timing, caching, and access control.
How does the @ decorator syntax actually work?
`@my_decorator` above a function is shorthand for `func = my_decorator(func)` — the decorator receives the function as an argument and returns a (usually wrapped) replacement. That returned function is what the name now points to.
Why use functools.wraps in a decorator?
Wrapping a function normally hides its name, docstring, and signature behind the wrapper. `@functools.wraps(func)` copies that metadata onto the wrapper so debuggers, tools, and `help()` still show the original function's identity.
Practice this in an interview
All questionsYou add one more layer of nesting: a factory function that accepts the decorator's arguments and returns the actual decorator. The @syntax then calls the factory first, and the result decorates the function.
Without functools.wraps, the wrapper function replaces the original's __name__, __doc__, __module__, __qualname__, and __annotations__, breaking introspection, logging, and documentation tools. functools.wraps copies all of these attributes from the wrapped function to the wrapper and stores a reference in __wrapped__ for unwrapping.
A decorator is a callable that takes a function, wraps it with extra behaviour, and returns the new callable. The @syntax is syntactic sugar for reassigning the function name to the wrapper immediately after definition.
`@property` turns a method into a descriptor that Python calls automatically on attribute access, letting you add validation or computation behind a dot-access interface without changing callers. Use it when a value is derived, needs guarding, or must be lazily computed — not as a default for every attribute.