Free Online Code Editor
Understand Python decorators by building a timer, a logger, a retry wrapper, and a cache, with the functools.wraps detail most tutorials skip.
Decorators have a reputation for being confusing. The idea is actually simple: a decorator is a function that takes another function and returns a changed version of it. That's all.
The @ symbol is just a shortcut. Once you see what it's shortcutting, the mystery goes away.
In Python, you can pass a function around like any other value. You can store it in a variable, hand it to another function, or return it.
def shout(text):
return text.upper() + "!"
def run_twice(func, value):
return func(func(value))
print(run_twice(shout, "hey"))HEY!!That's the foundation. A decorator is a function that takes a function, wraps some extra behavior around it, and returns the wrapper.
Let's write one that logs every call.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args} {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a, b):
return a + b
add = log_calls(add)
add(2, 3)Calling add with (2, 3) {}
add returned 5The line add = log_calls(add) replaces add with the wrapped version. Writing that by hand every time is tedious, so Python gives you the @ shortcut:
@log_calls
def multiply(a, b):
return a * b
multiply(4, 5)Calling multiply with (4, 5) {}
multiply returned 20@log_calls above a function means exactly the same as multiply = log_calls(multiply) underneath it.
The *args, **kwargs in the wrapper is what lets it accept whatever arguments the original function takes. If you need a refresher, see the args and kwargs tutorial.
There's a catch. After decoration, the function's name and docstring belong to the wrapper, not the original:
print(multiply.__name__)wrapperThat breaks debugging tools, help text, and anything that inspects function names. The fix is one line from the standard library:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
"""Say hello."""
return f"Hello, {name}"
print(greet.__name__)
print(greet.__doc__)
print(greet("Mia"))greet
Say hello.
Calling greet
Hello, MiaMake @wraps(func) a habit. Every decorator you write should have it.
A timer is the classic first real decorator. It tells you how long a function takes.
import time
from functools import wraps
def timed(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
@timed
def slow_sum(n):
return sum(range(n))
total = slow_sum(2_000_000)
print(total)slow_sum took 0.0312s
1999999000000Your time will differ. The point is that you can add timing to any function by adding one line above it, without touching its body.
Network calls fail. A retry decorator keeps that logic out of your main code.
import time
from functools import wraps
def retry(times=3, delay=0.1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as error:
print(f"Attempt {attempt} failed: {error}")
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
calls = {"n": 0}
@retry(times=3)
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError("server busy")
return "success"
print(flaky())Attempt 1 failed: server busy
Attempt 2 failed: server busy
successNotice there are three layers here. retry(times=3) runs first and returns a decorator. That decorator receives the function and returns the wrapper. When your decorator takes its own arguments, you need that extra outer layer.
Some functions are slow and get called with the same inputs over and over. Python already has a built-in decorator for this:
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(80))23416728348467685Without the cache, fib(80) would take longer than you'd want to wait. With it, every value is calculated once. Only cache functions that always return the same output for the same input, and be careful with large inputs, since the cache lives in memory.
You can apply more than one. They're applied from the bottom up, so the one closest to the function wraps it first.
@timed
@log_calls
def square(x):
return x * x
square(9)Calling square
square took 0.0000sHere log_calls wraps square, and then timed wraps that result. The order matters when decorators change arguments or return values.
Decorators work best for behavior that applies to many functions and isn't the function's main job: logging, timing, caching, retries, permission checks, and input validation. Web frameworks like Flask use them to register routes.
If you only need the extra behavior once, a decorator is overkill. Just write the code in the function.
@name is shorthand for func = name(func).*args, **kwargs inside the wrapper so it works with any signature.@wraps(func) to keep the original name and docstring.Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.
Open the Python editorA clear order for learning Python from zero, the topics you can safely ignore for now, and the mistakes that trip up almost every beginner.
Learn how Python list comprehensions work, with filtering, nested loops, dict and set versions, and clear rules for when a plain loop is the better choice.
See how Python generators and the yield keyword let you handle huge files and endless streams one item at a time, with clear examples and common gotchas.