If you have ever seen a line that starts with an @ symbol sitting on top of a Python function, you have met a decorator. Decorators are one of Python's most elegant features — they let you add new behaviour to a function or class without touching its original code. Logging, timing, access control, caching and retries are all built cleanly on top of this single idea.
This complete guide builds decorators up from first principles. We start with the one concept everything depends on (functions being objects), then write our first decorator, handle arguments and return values, fix the metadata problem with functools.wraps, build decorators that take parameters, stack them, write class-based decorators, and finish with five real-world examples you can drop into production. Every snippet is runnable, and its output is shown right below it.
What Is a Decorator in Python?
A decorator is a callable that takes another function (or class) as input, wraps extra behaviour around it, and returns a new callable. It follows the wrapper design pattern: the original function keeps doing its job, while the decorator adds something before and/or after it runs.
Syntax:
@decorator_name
def function_name():
...
# The line above is exactly equivalent to:
def function_name():
...
function_name = decorator_name(function_name)
So @decorator_name is just syntactic sugar — a shortcut for passing your function through another function and reassigning the result. Understanding that one equivalence is 80% of understanding decorators.
Prerequisite: Functions Are First-Class Objects
Decorators only work because in Python functions are first-class objects. That means a function can be assigned to a variable, passed as an argument, and returned from another function — just like an int or a list.
def shout(text):
return text.upper()
yell = shout # assign the function object to a new name
print(yell("hello")) # call it through that name
print(shout) # functions are just objects in memory
Output:
HELLO
<function shout at 0x7f9c2a1b5d30>
Nested functions and closures
A function defined inside another function can remember the variables of the enclosing function even after the outer function has returned. This is called a closure, and it is the engine inside every decorator.
def make_multiplier(n):
def multiplier(x):
return x * n # 'n' is remembered from the enclosing scope
return multiplier # return the inner function (not call it)
times3 = make_multiplier(3)
times5 = make_multiplier(5)
print(times3(10))
print(times5(10))
Output:
30
50
Writing Your First Decorator
Let's combine the two ideas. A decorator takes func, defines an inner wrapper that calls func with some extra behaviour around it, and returns wrapper.
def my_decorator(func):
def wrapper():
print("Before the function runs")
func()
print("After the function runs")
return wrapper
def say_hello():
print("Hello!")
# Manual decoration (no @ yet) to show what really happens:
say_hello = my_decorator(say_hello)
say_hello()
Output:
Before the function runs
Hello!
After the function runs
Now the same thing with the @ syntax — cleaner, but identical in behaviour:
@my_decorator
def say_hello():
print("Hello!")
say_hello()
Output:
Before the function runs
Hello!
After the function runs
Decorating Functions That Take Arguments
Our first wrapper() took no arguments, so it breaks the moment the wrapped function needs some. The fix is to accept *args and **kwargs in the wrapper and forward them to the original function. This makes the decorator work with any function signature.
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before")
result = func(*args, **kwargs) # forward all arguments
print("After")
return result
return wrapper
@my_decorator
def add(a, b):
return a + b
print(add(3, 5))
Output:
Before
After
8
Returning Values from the Wrapped Function
Notice the line result = func(*args, **kwargs) followed by return result in the example above. This is a step beginners forget constantly: if your wrapper does not return the result, the decorated function returns None.
def broken(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs) # result thrown away!
return wrapper
@broken
def add(a, b):
return a + b
print(add(3, 5)) # we lost the 8
Output:
None
Rule of thumb: a wrapper should almost always return func(*args, **kwargs) (or capture and return its value).
Preserving Metadata with functools.wraps
There is a subtle bug in every decorator we have written so far. Because the decorated name now points at wrapper, the original function's identity — its __name__, __doc__, and signature — is lost. This breaks debugging, help text, and tools that introspect functions.
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet():
"""Return a friendly greeting."""
return "Hi"
print(greet.__name__)
print(greet.__doc__)
Output:
wrapper
None
The fix is one line: decorate the wrapper with @wraps(func) from the functools module. It copies the metadata from the original function onto the wrapper.
from functools import wraps
def my_decorator(func):
@wraps(func) # <-- the fix
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet():
"""Return a friendly greeting."""
return "Hi"
print(greet.__name__)
print(greet.__doc__)
Output:
greet
Return a friendly greeting.
Always use functools.wraps in real decorators. From here on, every example does.
Decorators That Take Arguments (Parameterized Decorators)
Sometimes you want to configure a decorator, e.g. @repeat(times=3). This needs one extra layer: an outer function that accepts the parameters and returns the actual decorator. So a parameterized decorator is a function that returns a function that returns a function.
from functools import wraps
def repeat(times): # 1) takes the parameter
def decorator(func): # 2) the real decorator
@wraps(func)
def wrapper(*args, **kwargs): # 3) the wrapper
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Tushar")
Output:
Hello, Tushar!
Hello, Tushar!
Hello, Tushar!
@repeat vs @repeat(): a parameterized decorator must be called with parentheses (@repeat(times=3)). Writing@repeatwithout parentheses passes your function in as thetimesargument and breaks.
Stacking (Chaining) Multiple Decorators
You can apply more than one decorator to a function by stacking them. They are applied bottom-to-top (the one closest to the function runs first when wrapping).
from functools import wraps
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return "<b>" + func(*args, **kwargs) + "</b>"
return wrapper
def italic(func):
@wraps(func)
def wrapper(*args, **kwargs):
return "<i>" + func(*args, **kwargs) + "</i>"
return wrapper
@bold
@italic
def text():
return "hello"
print(text())
Output:
<b><i>hello</i></b>
Read it as bold(italic(text)): italic wraps first, then bold wraps that. Swapping the order to @italic then @bold would produce <i><b>hello</b></i>.
Class-Based Decorators
A decorator only has to be callable — it does not have to be a function. A class with a __call__ method works too, and is handy when the decorator needs to hold state (like a counter) between calls.
from functools import update_wrapper
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
update_wrapper(self, func) # class equivalent of @wraps
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__!r}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi()
say_hi()
Output:
Call 1 of 'say_hi'
Hi!
Call 2 of 'say_hi'
Hi!
Real-World Examples
This is where decorators earn their keep. Here are five patterns you will actually use.
1. Timing how long a function takes
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_square(n):
time.sleep(1)
return n * n
print(slow_square(5))
Output:
slow_square took 1.0008s
25
2. Caching / memoization with functools.lru_cache
You usually do not need to write a cache decorator by hand — the standard library ships one. @lru_cache stores results keyed by the arguments, turning an exponential recursive Fibonacci into a linear one.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(50))
print(fib.cache_info())
Output:
12586269025
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
Note: arguments must be hashable (no lists/dicts), and avoid @lru_cache on instance methods — it keeps every instance alive and can leak memory.
3. Requiring login / access control
from functools import wraps
def require_login(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("is_authenticated"):
raise PermissionError("Login required")
return func(user, *args, **kwargs)
return wrapper
@require_login
def view_dashboard(user):
return f"Welcome, {user['name']}!"
print(view_dashboard({"name": "Tushar", "is_authenticated": True}))
Output:
Welcome, Tushar!
4. Logging calls and return values
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def multiply(a, b):
return a * b
multiply(4, 5)
Output:
Calling multiply(args=(4, 5), kwargs={})
multiply returned 20
5. Retrying a flaky operation
A parameterized decorator that re-runs a function if it raises, up to max_attempts times — perfect for network calls.
import time
from functools import wraps
def retry(max_attempts=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data():
raise ConnectionError("server unreachable")
fetch_data()
Output:
Attempt 1 failed: server unreachable
Attempt 2 failed: server unreachable
Attempt 3 failed: server unreachable
Traceback (most recent call last):
...
ConnectionError: server unreachable
Common Mistakes to Avoid
- Forgetting
functools.wraps— your function loses its name, docstring and signature. - Not returning the result —
wrappermust returnfunc(...)'s value or the function silently returnsNone. - Omitting
*args, **kwargs— the decorator then only works for no-argument functions. - Confusing
@decowith@deco()— only parameterized decorators use the parentheses. - Wrong stacking order — remember decorators apply bottom-to-top.
- Caching unhashable arguments —
@lru_cacheneeds hashable args, and should not wrap instance methods.
Summary Table
| Type | Syntax | When to use |
|---|---|---|
| Basic decorator | @my_decorator | Add fixed behaviour before/after a function |
With *args/**kwargs | @my_decorator | Work with any function signature |
| Parameterized | @repeat(times=3) | Configure the decorator with options |
| Stacked | @a then @b | Combine multiple behaviours |
| Class-based | @CountCalls | Decorator needs to hold state |
| Built-in cache | @lru_cache | Memoize pure, expensive functions |
Conclusion
Decorators look like magic until you remember the one rule they are built on: @deco means func = deco(func). From there everything follows — wrappers, *args/**kwargs, returning results, functools.wraps, parameters, stacking and classes. Master this pattern and you will read frameworks like Flask, Django and FastAPI with far less mystery, because decorators are everywhere in them.
Start small: write a @timer for your slowest function today, then reach for @lru_cache the next time you see repeated work. You now have the complete mental model to build the rest yourself.
π¬ Comments (0)
No comments yet. Be the first to share your thoughts!