Decorators are one of those Python features that look like magic the first time you see the @ symbol, and then click permanently once you understand what’s actually happening underneath: a decorator is just a function that takes a function and returns a function. That’s the whole trick. Once that clicks, you can read (and write) decorators like @app.route, @lru_cache, or @pytest.fixture without treating them as syntax you memorize rather than code you understand.
This guide builds decorators from first principles, then walks through the patterns you’ll actually reach for in production code: timing, logging, retrying flaky calls, and caching expensive results.
Functions Are Just Objects
The foundation of decorators is that Python functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions, exactly like you would with a string or a list.
def greet(name):
return f"Hello, {name}!"
say_hi = greet # assign the function itself, not its result
print(say_hi("Ada")) # Hello, Ada!
def call_twice(func, arg):
func(arg)
func(arg)
call_twice(print, "ping") # prints "ping" twice
Because functions can be passed around, you can also define a function inside another function and return it. That nested, returned function is called a closure, and it’s the mechanism decorators are built on.
Building Your First Decorator
A decorator wraps a function with extra behavior without changing the original function’s code. Here’s a decorator that times how long a function takes to run:
import time
def timer(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(0.2)
return n * n
slow_square(5)
# slow_square took 0.2001s
The line @timer above def slow_square(n): is exactly equivalent to writing:
def slow_square(n):
time.sleep(0.2)
return n * n
slow_square = timer(slow_square)
timer receives the original slow_square function, defines a new wrapper function that calls it while measuring time, and returns wrapper. The name slow_square in your module now points to wrapper, not the original function — but wrapper calls the original internally, so behavior is preserved and extended.
Don’t Lose Function Metadata
There’s a subtle bug in the decorator above: once wrapped, slow_square.__name__ is now "wrapper", and its docstring is gone. This breaks introspection tools, debuggers, and documentation generators. The fix is functools.wraps, which copies over the original function’s metadata:
import functools
import time
def timer(func):
@functools.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
Always use @functools.wraps(func) on your inner wrapper. It’s a one-line addition that saves you from confusing stack traces and broken help() output later.
Decorators That Take Arguments
Sometimes you want to configure the decorator itself, like @retry(times=3). This requires an extra layer of nesting: a function that takes the decorator’s arguments and returns the actual decorator.
import functools
import time
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_exc = exc
print(f"Attempt {attempt} failed: {exc}")
if attempt < times:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
# imagine this sometimes raises ConnectionError
return call_flaky_api(url)
Here, retry(times=3, delay=0.5, ...) runs first and returns decorator. Python then applies decorator to fetch_data, exactly as in the simple case. This two-layer pattern (arguments → decorator → wrapper) is the standard shape for any configurable decorator, and it's worth memorizing because you'll see it constantly in real codebases.
A Practical Caching Decorator
Python's standard library already ships a production-grade caching decorator, functools.lru_cache, but building a simplified version is a great way to see how memoization works:
import functools
def simple_cache(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@simple_cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # instant, instead of exponential blowup
Notice the cache dictionary lives in the enclosing scope of wrapper, not as a global or an instance attribute. Each decorated function gets its own private cache, because each call to simple_cache creates a fresh dictionary. In real code, prefer @functools.lru_cache(maxsize=128) unless you need custom eviction or key logic — it's implemented in C, thread-safe, and battle-tested.
Class-Based Decorators
Decorators don't have to be functions. Any object with a __call__ method works, which is useful when the decorator needs to track state across calls, like a call counter:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
print(f"Call #{self.calls} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id):
return f"processed {order_id}"
process_order("A1")
process_order("A2")
print(process_order.calls) # 2
functools.update_wrapper(self, func) is the class-based equivalent of @functools.wraps, copying __name__, __doc__, and related attributes onto the instance.
Common Pitfalls
- Forgetting
*args, **kwargs: if your wrapper only accepts specific parameters, it breaks for any decorated function with a different signature. Always accept and forward both. - Mutable default cache keys: dictionaries and lists aren't hashable, so a naive cache decorator will throw a
TypeErrorif called with a list argument. Either restrict inputs to hashable types or convert them before using as a cache key. - Stacking order confusion: when you stack multiple decorators, they apply bottom-up.
@aabove@babovedef fmeansf = a(b(f)), sobruns closer to the original function andawraps everything, includingb's wrapper. - Decorating methods: when decorating instance methods, remember
selfis just the first positional argument — your*argscapture handles it automatically as long as you don't hardcode a different signature.
Conclusion
A decorator is nothing more than a function that wraps another function and returns the replacement. Everything else — configurable decorators, class-based decorators, caching, retries — is a variation on that one idea: take a function in, return a function (or callable) out. Start by reaching for the standard library's functools.wraps, lru_cache, and cached_property before writing your own, but understanding how they work internally makes debugging decorated code and reading unfamiliar frameworks dramatically less mysterious.