Decorators & Wrappers —
The Magic Behind @.
You've seen @staticmethod, @property, @login_required. But what's actually happening when Python sees that @? This is the complete mental model — from first-class functions all the way to decorators with arguments and class-based decorators.
What Is a Decorator?
A decorator is a function that takes another function as input and returns a new function — usually one that does something extra before or after calling the original.
That's the whole idea. Everything else is implementation detail.
The @decorator syntax is just shorthand. These two blocks are identical:
# With @ syntax @my_decorator def greet(): print("Hello!") # Without @ syntax — exactly equivalent def greet(): print("Hello!") greet = my_decorator(greet)
The @ sign is syntactic sugar. Python sees @my_decorator above a function definition and immediately executes greet = my_decorator(greet) after the function is defined. That's it. No magic.
To understand how my_decorator can receive a function and return another function, we need to understand first-class functions.
First-Class Functions
In Python, functions are objects. You can assign them to variables, store them in lists, pass them as arguments, and return them from other functions — the same way you'd handle a string or an integer.
def say_hello(): print("Hello!") # Assign to a variable — no () means no call, just reference fn = say_hello fn() # prints "Hello!" # Pass as an argument def run_twice(func): func() func() run_twice(say_hello) # prints "Hello!" twice # Return from a function def get_greeter(): def inner(): print("Hi from inner!") return inner # returning the function object, not calling it greeter = get_greeter() greeter() # prints "Hi from inner!"
The last example is key: get_greeter defines a function inside itself and returns it. The inner function is called a closure — it has access to variables from its enclosing scope even after the outer function has returned.
This is the mechanism decorators are built on.
Your First Decorator
Let's build a decorator that prints a log message before and after any function runs. This pattern is called a "wrapper" — you're wrapping the original function in new behaviour.
def log_calls(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__}...") result = func(*args, **kwargs) print(f"{func.__name__} finished.") return result return wrapper @log_calls def add(a, b): return a + b result = add(3, 5) # Calling add... # add finished. print(result) # 8
Walk through what's happening:
log_callsreceives theaddfunction asfunc- It defines a new function
wrapperthat callsfuncin the middle - It returns
wrapper— not calling it, just returning the object - Python replaces
addwith the returnedwrapper - From now on, calling
add(3, 5)actually callswrapper(3, 5), which calls the originaladd
The *args, **kwargs in wrapper's signature is important — it means "accept any arguments and pass them through." This makes the decorator generic: it works on functions with any signature.
Every decorator follows this shape: outer function receives func, defines an inner wrapper(*args, **kwargs) that calls func(*args, **kwargs), outer function returns wrapper. Memorise this skeleton — everything else is just adding logic before/after the func() call.
functools.wraps
There's a subtle problem with the decorator above. When you decorate a function, Python replaces it with wrapper. That means the original function's metadata — its name, docstring, and module — is gone:
@log_calls def add(a, b): """Adds two numbers.""" return a + b print(add.__name__) # "wrapper" — not "add"! print(add.__doc__) # None — docstring is gone!
This breaks introspection tools, auto-generated docs, and debugging. The fix is functools.wraps — a decorator for your wrapper that copies the original function's metadata onto it:
import functools def log_calls(func): @functools.wraps(func) # ← add this always def wrapper(*args, **kwargs): print(f"Calling {func.__name__}...") result = func(*args, **kwargs) print(f"{func.__name__} finished.") return result return wrapper @log_calls def add(a, b): """Adds two numbers.""" return a + b print(add.__name__) # "add" ✓ print(add.__doc__) # "Adds two numbers." ✓
Rule of thumb: always put @functools.wraps(func) on your wrapper function. No exceptions. The cost is one line; the benefit is that your decorator is invisible to everything that inspects function metadata.
Decorators with Arguments
What if you want to configure a decorator? Something like @retry(times=3) or @cache(ttl=60)? This requires one more layer of nesting — a function that creates and returns a decorator.
import functools def repeat(times): # This is the "decorator factory" — it receives the config def decorator(func): # This is the actual decorator — it receives the function @functools.wraps(func) def wrapper(*args, **kwargs): # This is the wrapper — it does the actual work for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(times=3) def say_hi(): print("Hi!") say_hi() # Hi! # Hi! # Hi!
Three layers, three jobs:
- Outermost function (
repeat) — receives decorator arguments, returns the decorator - Middle function (
decorator) — receives the function to decorate, returns the wrapper - Inner function (
wrapper) — receives the call arguments, does the work
When you write @repeat(times=3), Python first calls repeat(times=3) to get a decorator, then applies that decorator to the function. The parentheses trigger the outer call; the @ triggers the middle call.
Think of it as: repeat(3)(say_hi). The first call returns a decorator. The second call applies it. The @ syntax is doing both for you.
Real Patterns
Here are three decorators you'll write or reach for constantly in real Python projects.
@timer — measure execution time
import time, functools 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_query(): time.sleep(0.5) return "done" slow_query() # slow_query took 0.5002s
@retry — retry on exception
import functools, time def retry(times=3, delay=1.0, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except exceptions as e: if attempt == times - 1: raise print(f"Attempt {attempt+1} failed: {e}. Retrying...") time.sleep(delay) return wrapper return decorator @retry(times=3, delay=0.5, exceptions=(ConnectionError,)) def fetch_data(url): # might fail on flaky network return requests.get(url)
@cache — simple memoization
import functools def cache(func): store = {} @functools.wraps(func) def wrapper(*args): if args not in store: store[args] = func(*args) return store[args] return wrapper @cache def fibonacci(n): if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2) # fibonacci(40) without cache: ~40 seconds # fibonacci(40) with cache: instant
Python's standard library has functools.lru_cache and (since 3.9) functools.cache — both are production-grade memoization decorators. Use those in real code. Building your own is a great exercise for understanding how they work.
Class Decorators
A decorator doesn't have to be a function. Any callable works — and a class with a __call__ method is callable. Class decorators can be useful when your decorator needs to maintain state across multiple calls.
import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.call_count = 0 def __call__(self, *args, **kwargs): self.call_count += 1 print(f"Call #{self.call_count}") return self.func(*args, **kwargs) @CountCalls def process(x): return x * 2 process(5) # Call #1 process(5) # Call #2 process(5) # Call #3 print(process.call_count) # 3
When @CountCalls is applied, Python calls CountCalls(process) — which hits __init__ and stores the function. Every subsequent call to process(x) hits __call__. The instance's call_count persists across all calls — something you'd need a closure variable to achieve with a function decorator.
Use class decorators when your decorator has meaningful state. Use function decorators for everything else — they're lighter and simpler.
Stacking & Cheatsheet
You can stack decorators. They apply bottom-up — the decorator closest to the function definition applies first.
@timer # applied second (outermost) @log_calls # applied first (innermost) def my_func(): ... # Equivalent to: my_func = timer(log_calls(my_func))
Calling my_func() runs through timer first, then log_calls, then the original function, then back out.
Quick reference for everything we covered: