A decorator is a function that takes another function and returns a replacement for it. That is the entire mechanism. The @ symbol, functools.wraps, and the three-level nesting you see in library code are all conveniences layered on top of that one idea. This tutorial builds decorators up from first principles, with complete examples you can run as-is, so that @lru_cache or @app.route stops looking like magic.
Functions are objects
In Python, a function is an object like any other. You can assign it to a variable, store it in a list, pass it to another function, and return it from one.
def shout(text):
return text.upper()
speak = shout # no parentheses: referencing, not calling
print(speak("hello")) # HELLO
print(shout.__name__) # shout
speak = shout copies a reference to the function object; speak("hello") calls it. Keeping this distinction straight — referencing versus calling — is the single most useful habit for understanding decorators.
Because functions are objects, one function can build and return another:
def make_multiplier(n):
def multiply(x):
return x * n
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
multiply remembers the n it was created with, even after make_multiplier has returned. This is a closure, and closures are the machinery decorators run on.
A decorator, written by hand
Suppose you want to log every call to a function without editing the function itself. Wrap it:
def log_calls(func):
def wrapper():
print(f"Calling {func.__name__}")
result = func()
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
def greet():
return "hi"
greet = log_calls(greet)
greet()
Walk through it. log_calls(greet) receives the original function, defines wrapper around it, and returns wrapper. The line greet = log_calls(greet) then rebinds the name greet to that wrapper. From this point on, calling greet() runs the wrapper, and the wrapper reaches the original function through the closed-over func reference.
That reassignment line is a decorator. No @ involved yet.
The @ syntax
The @ syntax is shorthand for exactly that reassignment:
def log_calls(func):
def wrapper():
print(f"Calling {func.__name__}")
return func()
return wrapper
@log_calls
def greet():
return "hi"
greet() # prints "Calling greet"
@log_calls directly above def greet() means greet = log_calls(greet), applied the moment the function is defined. It is syntax, not a new mechanism — anything you can write with @ you can write as a plain assignment, which is worth remembering when something goes wrong and you need to debug it step by step.
Note that @log_calls has no parentheses: the decorator receives the function object itself. Decorators that do take parentheses, like @retry(times=3), are a separate pattern covered below.
Handling arguments with *args and **kwargs
The wrapper above only works for functions that take no arguments. A general-purpose decorator must forward whatever the wrapped function accepts, and *args / **kwargs do that without the decorator needing to know the signature:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}, {kwargs}")
result = func(*args, **kwargs)
print(f"-> {result!r}")
return result
return wrapper
@log_calls
def add(a, b, *, round_to=None):
total = a + b
return round(total, round_to) if round_to is not None else total
print(add(2.5, 3.14159, round_to=2))
Two details matter. First, the wrapper collects any positional and keyword arguments and passes them through unchanged. Second, it captures the return value and returns it. Forgetting that return is the most common decorator bug: the wrapped function still runs, its side effects still happen, but every call silently evaluates to None.
functools.wraps: keep the function's identity
Wrapping a function replaces it, and the replacement carries its own metadata:
def log_calls(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__) # wrapper -- not add
print(add.__doc__) # None -- the docstring is gone
This is worse than cosmetic. help(add) now shows nothing useful, tracebacks and profilers report wrapper for every decorated function in your codebase, and documentation tools cannot find the docstring. When several different decorators all produce functions named wrapper, debugging gets genuinely harder.
functools.wraps fixes it by copying the original function's metadata — __name__, __doc__, __module__, and a __wrapped__ reference back to the original — onto the wrapper:
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Return the sum of a and b.
functools.wraps is itself a decorator with an argument. Use it in every decorator you write; it costs one line and there is no good reason to omit it.
Decorators with arguments
@retry(times=3) needs one more layer than @log_calls: an outer function that receives the arguments and returns the actual decorator.
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def beep():
print("beep")
beep()
Read it inside-out. repeat(times=3) is an ordinary function call that returns decorator. The @ then applies decorator to beep, exactly as in the two-level case. So the three levels are: a factory that takes the configuration, a decorator that takes the function, and a wrapper that takes each call's arguments. Each level closes over the one above it, which is why wrapper can see both times and func.
Three practical decorators
Timing a function is the classic first real use:
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000)
The try/finally ensures the timing is printed even when the function raises, which is usually when you most want it.
Retrying is the standard answer to flaky operations such as network calls:
import functools
import time
def retry(times=3, delay=0.1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_error = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_error
return wrapper
return decorator
calls = {"count": 0}
@retry(times=3)
def flaky():
calls["count"] += 1
if calls["count"] < 3:
raise ConnectionError("temporary failure")
return "ok"
print(flaky()) # succeeds on the third attempt
A validation guard keeps checks out of the function body:
import functools
def require_positive(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for value in args:
if isinstance(value, (int, float)) and value <= 0:
raise ValueError(
f"{func.__name__} requires positive numbers, got {value}"
)
return func(*args, **kwargs)
return wrapper
@require_positive
def area(width, height):
return width * height
print(area(3, 4)) # 12
try:
area(-1, 4)
except ValueError as exc:
print(exc) # area requires positive numbers, got -1
The same shape covers authentication in web frameworks: check something about the caller, then either forward the call or refuse it. Paste any of these into the free browser editor and run it — it is real CPython running in your browser, so the behaviour matches your local interpreter exactly.
Stacking decorators and their order
Multiple decorators apply bottom-up: the one nearest the function wraps first.
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def title(text):
return text
print(title("Decorators")) # <b><i>Decorators</i></b>
The stack is equivalent to title = bold(italic(title)): italic wraps the original function, then bold wraps the result. At call time the outermost decorator runs first on the way in and last on the way out. Order matters whenever the decorators interact — put @timed above @retry and you measure all attempts together; put it below and you measure each attempt separately. Neither is wrong; they answer different questions.
Class-based decorators, briefly
Any callable can be a decorator, so a class with a __call__ method works too. It is the natural choice when the wrapper needs state you want to inspect from outside:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def ping():
return "pong"
ping()
ping()
print(ping.count) # 2
@CountCalls means ping = CountCalls(ping), so ping is now an instance, and calling it invokes __call__. functools.update_wrapper is the non-decorator form of wraps. Prefer plain function decorators for most work; reach for a class when attributes like count earn their keep.
Common mistakes
Forgetting to return the wrapper's result. A wrapper that calls func(*args, **kwargs) without returning it makes every decorated function return None. Because side effects still happen, this bug can hide for a long time — nothing fails until some caller finally reads the return value. Write return func(*args, **kwargs) by default.
Skipping functools.wraps. The symptoms are indirect: tracebacks full of functions named wrapper, help() output that says nothing, tools that dispatch on __name__ misbehaving. By the time you notice, the decorator may be applied in fifty places. Add @functools.wraps(func) the moment you write a wrapper.
Calling the function instead of referencing it. @timed() fails because timed() is called with no arguments and its return value — a wrapper closed over nothing — is used as the decorator. The reverse mistake is quieter: with a factory like retry(times=3), writing bare @retry passes your function in as times. Nothing fails at decoration time; the call blows up later, far from the cause. The rule: a plain decorator is used bare, a decorator factory is always called, even as @retry() with defaults.
Mutable state in closures. Closure variables are created once, when the decorator is applied, not on every call:
def cache_result(func):
results = {}
def wrapper(*args):
if args not in results:
results[args] = func(*args)
return results[args]
return wrapper
@cache_result
def square(n):
print(f"computing {n}")
return n * n
print(square(4)) # computing 4, then 16
print(square(4)) # 16 -- cached, no recomputation
Here the sharing is the point — that is a working memoiser. But the same behaviour is a bug if you expected a fresh results per call, and the dict grows without bound because nothing ever evicts entries. Be deliberate about which lifetime you want: created at decoration time and shared across calls, or created inside wrapper and private to each call. For production caching, use functools.lru_cache, which handles eviction and thread safety for you.
Where to go next
You now have the full mental model: functions are objects, a decorator is a function returning a wrapper, @ is assignment shorthand, and everything else is layering. The graded exercises for decorators — building a memoiser, repairing a broken wrapper, reasoning about stacking order — are in the interactive lesson at the top of this page (Pro). If you are earlier in your Python journey, the first lessons of the track are free, and every example in this tutorial runs unchanged in the browser.