The first time you see a Python decorator, it looks like magic. The function below has an @ symbol stuck on top of it, and somehow it now logs every call:
@log_calls
def add(a, b):
return a + b
Magic is where bugs come from. So let’s pull this apart and see what’s actually going on. By the end of this post you’ll be able to read, write, and debug any decorator in any Python codebase.
The one-line definition
A decorator is just a function that takes a function and returns a function.
That’s the whole concept. The @ syntax is sugar.
@log_calls
def add(a, b):
return a + b
is exactly equivalent to:
def add(a, b):
return a + b
add = log_calls(add)
log_calls is a function. It takes add (a function) as input. It returns a new function (also called add) that wraps the original. Every time the rest of your code calls add(2, 3), it’s actually calling the wrapper.
That’s it. There is no magic.
A complete decorator from scratch
Let’s build log_calls:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f" → {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
# Calling add((2, 3), {})
# → 5
Three things to notice:
log_callstakes a function (func) and returns a function (wrapper).- The
wrapperaccepts*args, **kwargsso it works for any signature. - Inside
wrapper, we call the originalfuncwith whatever was passed in, and return its result.
This is the canonical decorator pattern. 95% of decorators look exactly like this.
Always use functools.wraps
There’s a subtle bug in the version above:
print(add.__name__) # 'wrapper' ← uh oh
print(add.__doc__) # None ← we lost the docstring
When you replace add with wrapper, you lose all the metadata. Help text, name, signature — gone. This breaks debuggers, IDEs, help(), and a lot of testing tools.
The fix is functools.wraps:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f" → {result}")
return result
return wrapper
@wraps(func) copies __name__, __doc__, __module__, __qualname__, __annotations__, and a few other attributes from func onto wrapper. Always use it. I’ve never seen a decorator in production code that shouldn’t have it.
Decorators with arguments
What if you want to configure your decorator? Like @retry(times=3) instead of @retry?
The trick: you need another layer. A function that returns a decorator.
from functools import wraps
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == times:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(times=3, delay=0.5)
def fetch():
response = httpx.get("https://flaky-api.example.com")
response.raise_for_status()
return response.json()
Walk through it:
retry(times=3, delay=0.5)is called first. It returnsdecorator.@decoratoris applied tofetch. It returnswrapper.fetchis nowwrapper, which retries up to 3 times.
Three layers. Once you see this pattern, you’ll see it everywhere.
Decorators with state — use a class
Sometimes you want the decorator to remember things across calls. Class-based decorators are perfect for this:
from functools import wraps
class CallCounter:
def __init__(self, func):
wraps(func)(self)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CallCounter
def hello():
print("hi")
hello()
hello()
hello()
print(hello.count) # 3
The instance is the decorated function. Calling hello() invokes __call__, which bumps the counter and forwards to the real function.
This pattern is great when:
- You need to attach state to the decorated function.
- You want the decorator itself to expose methods (
.reset(),.stats(), etc.). - The decorator logic is complex enough that a class is clearer than nested functions.
Decorating methods (gotcha)
When you decorate methods, don’t forget about self:
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
class Calculator:
@log_calls
def add(self, a, b):
return a + b
This works because *args happily catches self along with the other arguments. The wrapper doesn’t care. Just remember: args[0] will be the instance.
Stacking decorators
You can apply multiple decorators. They’re applied bottom-up:
@log_calls
@retry(times=3)
def fetch():
...
is equivalent to:
fetch = log_calls(retry(times=3)(fetch))
Reading top-to-bottom: the call passes through log_calls first, then retry, then the real function. Order matters! Logging the retries vs. logging the call are different.
Decorators in real codebases
Here are the patterns you’ll meet most often:
@property
Built-in. Turns a method into an attribute.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return math.pi * self.radius ** 2
c = Circle(5)
c.area # 78.539... — no parentheses!
@staticmethod / @classmethod
Built-in. Mark a method as not needing the instance / needing the class instead.
@functools.lru_cache
Memoize a pure function:
from functools import lru_cache
@lru_cache(maxsize=128)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Saves repeated work. But: only for pure functions (same input → same output, no side effects). If your function calls a database or talks to the network, lru_cache will return stale results.
@dataclass
Auto-generates __init__, __repr__, __eq__:
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
Covered in detail in 10 Modern Python Tips That Will Quietly Make You Better .
Framework decorators
Django’s @login_required, FastAPI’s @app.get("/"), Flask’s @app.route("/") — all decorators. Same pattern as everything in this post.
A useful real-world decorator: timed
Here’s one you’ll actually want in your toolbox:
import time
from functools import wraps
from typing import Callable
def timed(label: str | None = None) -> Callable:
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
name = label or func.__name__
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = (time.perf_counter() - start) * 1000
print(f"[timed] {name}: {elapsed:.2f}ms")
return wrapper
return decorator
@timed("expensive query")
def fetch_users():
...
Drop it on any function and you get a per-call duration log. Great for tracking down slow code paths during development.
Common mistakes
- Forgetting
@wraps(func). Discussed above. - Wrapping a function but not returning the wrapper. A decorator that returns
Nonewill break the call site silently. - Calling the decorator instead of using
@.@retry(times=3)works;@retry(without parens) callsretry(func)which is wrong ifretryexpects config arguments. - Catching exceptions you didn’t mean to swallow. A
try/except: passinside a wrapper can hide real bugs. - Doing expensive setup in the outer function. Code outside the wrapper runs once at decoration time. Code inside the wrapper runs on every call. Be deliberate about which is which.
Async decorators
Decorating async functions is the same pattern, just with async wrappers:
from functools import wraps
import asyncio
def async_timed(label: str | None = None):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
name = label or func.__name__
start = asyncio.get_event_loop().time()
try:
return await func(*args, **kwargs)
finally:
elapsed = (asyncio.get_event_loop().time() - start) * 1000
print(f"[async-timed] {name}: {elapsed:.2f}ms")
return wrapper
return decorator
@async_timed("fetch user")
async def fetch_user(user_id: int):
...
The wrapper is async def, and it awaits the wrapped function. Otherwise, identical pattern.
If you’re new to async/await, see A Practical Guide to Python Async/Await
.
Conclusion
Decorators stop being magic the moment you see them as “functions that return functions.” Once you’ve internalized the pattern — outer wrapper, inner function, @wraps(func), optionally a third layer for arguments — you can read, write, and debug any decorator in any Python codebase.
The next time someone says “wow, that’s clever” about a decorator, you’ll know it’s just three nested functions with a clean syntax. That’s the best kind of clever.
Happy decorating!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .