Decorators are one of Python's most powerful features — they let you wrap a function (or class) with reusable behaviour without touching its body. One @ symbol and you can add logging, timing, caching, authentication, or retry logic to any function.
Quick Reference
| Pattern | Syntax | Use case |
|---|---|---|
| Basic decorator | @my_decorator |
Wrap any function |
| Decorator factory | @repeat(times=3) |
Decorator with arguments |
| Preserve metadata | @functools.wraps(fn) |
Keep __name__, __doc__ |
| Class decorator | @dataclass |
Modify/replace a class |
| Built-in: property | @property |
Getter as attribute |
| Built-in: classmethod | @classmethod |
Receives cls, not self |
| Built-in: staticmethod | @staticmethod |
No implicit first arg |
| Stack decorators | Multiple @ lines |
Apply in bottom-up order |
What Is a Decorator?
A decorator is a callable that takes a function and returns a function. The @ syntax is shorthand for passing a function into another function:
@my_decorator
def greet():
print("Hello")
# Exactly equivalent to:
def greet():
print("Hello")
greet = my_decorator(greet)
Decorators work because Python functions are first-class objects — you can pass them as arguments, return them, and store them in variables.
Your First Decorator
def shout(fn):
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
print("Done!")
return result
return wrapper
@shout
def greet(name):
print(f"Hello, {name}")
greet("Alice")
# Hello, Alice
# Done!
The wrapper function accepts *args, **kwargs so it works with any function signature.
Always Use functools.wraps
Without functools.wraps, the decorated function loses its identity:
import functools
def shout(fn):
@functools.wraps(fn) # ← preserves __name__, __doc__, __module__
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
print("Done!")
return result
return wrapper
@shout
def greet(name):
"""Say hello."""
print(f"Hello, {name}")
print(greet.__name__) # greet (not "wrapper")
print(greet.__doc__) # Say hello.
Without @functools.wraps, greet.__name__ would be "wrapper", breaking introspection tools, debuggers, and help().
Practical Patterns
Timing (Performance Measurement)
import functools
import time
def timer(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_query(n):
time.sleep(n)
return n * 2
slow_query(0.1)
# slow_query took 0.1002s
Logging
import functools
import logging
logging.basicConfig(level=logging.INFO)
def log_calls(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
logging.info("Calling %s with args=%s kwargs=%s", fn.__name__, args, kwargs)
result = fn(*args, **kwargs)
logging.info("%s returned %s", fn.__name__, result)
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 4)
# INFO:root:Calling add with args=(3, 4) kwargs={}
# INFO:root:add returned 7
Retry with Exponential Backoff
import functools
import time
def retry(max_attempts=3, delay=1.0, backoff=2.0, exceptions=(Exception,)):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
attempt = 0
wait = delay
while attempt < max_attempts:
try:
return fn(*args, **kwargs)
except exceptions as e:
attempt += 1
if attempt >= max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {wait}s…")
time.sleep(wait)
wait *= backoff
return wrapper
return decorator
@retry(max_attempts=4, delay=0.5, exceptions=(IOError, TimeoutError))
def fetch_data(url):
# might raise IOError on network failure
...
Simple Cache (Memoize)
import functools
def memoize(fn):
cache = {}
@functools.wraps(fn)
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(40)) # fast, even for large n
Tip: Python's standard library already has
@functools.lru_cache(maxsize=128)and@functools.cache(unbounded, Python 3.9+). Prefer those over a hand-rolled version.
from functools import lru_cache
@lru_cache(maxsize=256)
def expensive(n):
...
Access Control / Authentication
import functools
def require_auth(fn):
@functools.wraps(fn)
def wrapper(request, *args, **kwargs):
if not request.get("user"):
raise PermissionError("Authentication required")
return fn(request, *args, **kwargs)
return wrapper
@require_auth
def get_profile(request):
return {"name": request["user"]}
Decorator Factories (Decorators with Arguments)
When you need to pass arguments to a decorator, add one more level of nesting:
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 say_hi():
print("Hi!")
say_hi()
# Hi!
# Hi!
# Hi!
The call chain: repeat(times=3) returns decorator, which then receives say_hi.
Stacking Decorators
You can apply multiple decorators. They execute bottom-up at decoration time, and top-down at call time:
@timer # applied second (outermost wrapper)
@log_calls # applied first (innermost wrapper)
def process(data):
return sorted(data)
# Equivalent to:
# process = timer(log_calls(process))
When process(data) is called: timer's wrapper runs first, then log_calls's wrapper, then the real function.
Class Decorators
Decorators work on classes too. @dataclass is the most common example:
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
tags: list = field(default_factory=list)
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0, tags=[])
print(p == Point(1.0, 2.0)) # True — __eq__ generated for free
@dataclass generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, etc.
You can also write your own class decorator:
def singleton(cls):
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Config:
def __init__(self):
self.debug = False
Built-in Decorators
@property
Turns a method into a read-only attribute, with optional setter:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be non-negative")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.area) # 78.54…
c.radius = 10 # uses setter
c.radius = -1 # raises ValueError
@classmethod and @staticmethod
class Date:
def __init__(self, year, month, day):
self.year, self.month, self.day = year, month, day
@classmethod
def from_string(cls, s): # cls = Date
year, month, day = map(int, s.split("-"))
return cls(year, month, day) # works in subclasses too
@staticmethod
def is_leap(year): # no cls, no self
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
d = Date.from_string("2026-07-14")
print(Date.is_leap(2024)) # True
6 Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Forgetting functools.wraps |
fn.__name__ becomes "wrapper", breaks introspection |
Add @functools.wraps(fn) to wrapper |
Forgetting return result |
Decorated function always returns None |
Always return fn(*args, **kwargs) |
| Calling the function inside decorator | @expensive() vs @expensive confusion |
@decorator is no-call; @factory() calls factory |
Not forwarding *args, **kwargs |
Wrapper breaks if function takes arguments | Use wrapper(*args, **kwargs) |
| Mutable default in decorator | Cache shared across all decorated functions (bug) | Define cache = {} inside decorator, not outer scope |
Decorator on a method ignoring self |
wrapper discards self, breaks instance methods |
*args captures self automatically — no action needed |
6 FAQ
Q: What's the difference between @decorator and @decorator()?
@decorator passes the function directly to decorator. @decorator() calls decorator() first (returns another callable), then passes the function to that. Use () when your decorator needs arguments.
Q: Can a decorator return something other than a function?
Yes. A decorator can return any callable — a function, a lambda, an instance of a class with __call__. It can also return the original function unchanged after inspecting or registering it.
Q: How does @functools.cache differ from @functools.lru_cache?
@functools.cache (Python 3.9+) is equivalent to @lru_cache(maxsize=None) — it caches all calls forever and is slightly faster. Use lru_cache when you need a bounded cache to limit memory.
Q: Do decorators work with async functions?
Yes, but the wrapper must also be async def and use await:
import functools
def async_timer(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = await fn(*args, **kwargs)
print(f"{fn.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
Q: What order do stacked decorators run? At decoration time: bottom-up (innermost first). At call time: top-down (outermost first). Think of it as nested function calls — the outermost wrapper runs first when you call the function.
Q: When should I use a decorator vs a context manager?
Use a decorator when the behaviour applies to an entire function (retry logic, logging, caching). Use a context manager (with statement) when the behaviour has a clear enter/exit that should wrap a block of code (file handles, transactions, locks).