Python Decorators Demystified
Build decorators from scratch: closures, @functools.wraps, decorators with arguments, and real-world patterns for timing, logging, caching, and retry logic.
Decorators look like magic the first time you see them. The @ syntax, the nested functions, the fact that they somehow modify your function's behavior without changing its code. But there's no magic -- just functions wrapping functions. Once you see the mechanics, the whole thing clicks.
Prerequisites: Functions Are Objects
In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions:
def greet(name):
return f"Hello, {name}"
say_hello = greet # assign to a variable
print(say_hello("Alice")) # Hello, Alice
def call_twice(func, arg):
return func(arg) + " " + func(arg)
print(call_twice(greet, "Bob")) # Hello, Bob Hello, Bob
This isn't a trick -- it's just how Python works. Functions are objects with a __call__ method.
Closures: The Foundation
A closure is a function that remembers variables from its enclosing scope:
def make_multiplier(factor):
def multiply(x):
return x * factor # 'factor' is captured from the outer scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
make_multiplier returns a new function each time, and each returned function "remembers" its own factor. This is the mechanism decorators are built on.
Building a Decorator Step by Step
A decorator is a function that takes a function and returns a modified version of it. Here's the simplest one:
def log_calls(func):
def wrapper(args, *kwargs):
print(f"Calling {func.__name__}")
result = func(args, *kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
You can use it manually:
def add(a, b):
return a + b
add = log_calls(add) # replace 'add' with the wrapped version
add(3, 4)
# Calling add
# add returned 7
The @ syntax is just shorthand for exactly this:
@log_calls
def add(a, b):
return a + b
# This is identical to: add = log_calls(add)
That's it. That's all @decorator does. It passes your function through another function and replaces it with the result.
Why You Need @functools.wraps
There's a problem with our decorator. The wrapped function loses its identity:
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # "wrapper" -- wrong!
print(add.__doc__) # None -- our docstring is gone!
The fix is @functools.wraps, which copies the original function's metadata onto the wrapper:
import functools
def log_calls(func):
@functools.wraps(func) # <-- preserves __name__, __doc__, etc.
def wrapper(args, *kwargs):
print(f"Calling {func.__name__}")
result = func(args, *kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # "add" -- correct
print(add.__doc__) # "Add two numbers." -- preserved
Always use @functools.wraps. Skipping it breaks help(), documentation generators, and anything else that inspects function metadata. It takes five seconds to add and prevents real debugging headaches.
Decorators with Arguments
What if you want your decorator to be configurable? You need another layer of nesting:
import functools
def retry(max_attempts=3, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(args, *kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(args, *kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
return wrapper
return decorator
@retry(max_attempts=5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
# might fail due to network issues
...
Three levels: the outermost function takes the decorator's arguments, the middle function takes the decorated function, and the inner function is the actual wrapper. It looks overwhelming at first, but the pattern is always the same.
Real-World Decorator Patterns
Timing: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
@timer
def slow_function():
time.sleep(1)
return "done"
Simple authentication check:
import functools
def require_auth(func):
@functools.wraps(func)
def wrapper(request, args, *kwargs):
if not request.user.is_authenticated:
raise PermissionError("Login required")
return func(request, args, *kwargs)
return wrapper
@require_auth
def delete_account(request):
...
Memoization (caching):
You could write your own, but Python already has one:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(100) # instant, thanks to caching
@lru_cache stores results for recent inputs and returns cached values instead of recomputing. It turns the naive recursive Fibonacci from exponential to linear.
Built-in Decorators You Already Know
Several Python features are implemented as decorators:
@property-- turns a method into a computed attribute@staticmethod-- method that doesn't receiveselforcls@classmethod-- method that receives the class as first argument@functools.lru_cache-- memoization@dataclasses.dataclass-- auto-generates__init__,__repr__,__eq__@abc.abstractmethod-- marks methods that subclasses must implement
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
# __init__, __repr__, __eq__ all generated automatically
Stacking Decorators
You can apply multiple decorators. They apply bottom-up:
@timer
@require_auth
def important_operation(request):
...
# Equivalent to:
# important_operation = timer(require_auth(important_operation))
The function first gets wrapped by require_auth, then that result gets wrapped by timer. So when called, timer's wrapper runs first (starts the clock), then require_auth's wrapper checks authentication, then the actual function executes.
Decorators are one of those features that separate "I can write Python" from "I understand Python." The pattern comes up everywhere -- web frameworks, testing libraries, CLI tools. Once you're comfortable writing your own, you can read framework source code with much more confidence.
If you want to practice building decorators from scratch -- starting with simple wrappers and working up to parameterized decorators -- CodeUp has Python exercises that walk through these patterns interactively.