Advanced Python Decorators Patterns: Arguments and Stacking
The patterns you'll actually encounter in real codebases.

You've learned how decorators work. You've survived the print vs return trap. Now it's time for the patterns you'll actually encounter in the wild: decorators that take arguments and decorators that stacked on top of each other.
If you've ever wondered how FastAPI's @app.get("/shipments") work under the hood, this is the article for you.
The Problem with Simple Decorators
Our decorators so far have all looked like this:
@my_decorator
def some_function():
pass
The decorator receives the function, wraps it, and returns the wrapper. Clean and simple.
But what if you want a decorator that repeats a function a configurable number of times? You want to use it like this:
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
And on another function:
@repeat(5)
def say_goodbye():
print("Goodbye!")
Same decorator, different configuration. How do we build this?
Your First Instinct Is Wrong (And That's OK)
Your first thought might be: "Just add the number as another parameter."
def repeat(func, n): # <-- this won't work with @
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
But this breaks with the @ syntax. When Python sees @repeat(3), it calls @repeat(3) before it knows about the function. So repeat receives 3, not a function. There's no way to pass both the number and the function at the same time.
@repeatand@repeat(3)are not the same pattern. The first expectrepeatitself to be a decorator. The second expectrepeat(3)to return a decorator.
Understanding What @repeat(3) Actually Does
Let's slow down and think about what Python does when it sees:
@repeat(3)
def say_hello():
print("Hello!")
It evaluates this in two steps:
First, it calls
repeat(3). This must return a decorator.Then, it applies that decorator to
say_hello.
Written out explicitly:
# Step 1: repeat(3) returns a decorator
decorator = repeat(3)
# Step 2: that decorator wraps say_hello
say_hello = decorator(say_hello)
Or in one line: say_hello = repeat(3)(say_hello).
This means repeat is not the decorator itself anymore. It's a function that builds and returns a decorator. One extra layer.
This wrapping happens once, when Python executes the
defstatement. After that, the namesay_hellono longer refers to the original function. It refers to the wrapper returned by the decorator.
Building It Step by Step
Let's start with something we already know, a decorator with the number hardcoded:
from functools import wraps
def repeat_three(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(3):
result = func(*args, **kwargs)
return result
return wrapper
@repeat_three
def say_hello():
print("Hello!")
functools.wraps(func) on the wrapper so the decorated function keeps metadata like its original name and docstring.This works, but 3 is baked in. To make it configurable, wrap the whole thing in a function that takes n:
from functools import wraps
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
Three layers. Read them from the outside in:
repeat(n)takes your configuration argumentdecorator(func)takes the function being decorated (this is the actual decorator)wrapper(*args, **kwargs)replaces the original function
That's it. Now it works with any number:
@repeat(3)
def say_hello():
print("Hello!")
@repeat(5)
def say_goodbye():
print("Goodbye!")
say_hello()
# Hello!
# Hello!
# Hello!
say_goodbye()
# Goodbye! (x5)
Notice that
repeatreturns the result from the final execution of the function. If the function returns different values on each call, only the last one is preserved.
n were 0, this implementation would return None, so in real code you may want to validate that n >= 1.Let's Trace Through the Execution
When Python sees @repeat(3) above say_hello, here's exactly what happens:
Python calls
repeat(3)repeat(3)returns thedecoratorfunction, withn=3remembered (closure)Python applies that decorator to
say_hello:decorator(say_hello)decorator(say_hello)returns thewrapperfunction, withfunc=say_hellorememberedsay_hellois now replaced bywrapperWhen you call
say_hello(), you're callingwrapper()wrapperruns the originalsay_hello3 times (becausen=3is remembered)
Closures are doing all the heavy lifting here. Each layer remembers the values from the layer above it.
A closure is a function that remembers variables from the scope where it was created, even after that outer function has finished running.
Practical Example: A Retry Decorator
Here's a decorator that retries a function if it throws an exception:
from functools import wraps
import time
def retry(max_attempts):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt < max_attempts:
print("Retrying...")
time.sleep(1)
else:
print("All attempts failed!")
raise # Re-raise the exception if all attempts exhausted
return wrapper
return decorator
@retry(3)
def connect_to_server():
print("Trying to connect...")
raise ConnectionError("Server is down")
connect_to_server()
# Trying to connect...
# Attempt 1 failed: Server is down
# Retrying...
# Trying to connect...
# Attempt 2 failed: Server is down
# Retrying...
# Trying to connect...
# Attempt 3 failed: Server is down
# All attempts failed!
Notice how the return inside the try block exits the wrapper immediately on success. The loop only continues if an exception is caught.
This is a simplified example for learning. Real retry logic usually catches specific exceptions, may use exponential backoff, and often avoids retrying on every kind of failure.
Practical Example: Role-Based Access Control
from functools import wraps
def require_role(role):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
current_user_role = "editor" # imagine this from a database
if current_user_role != role:
print(f"Access denied! You need '{role}' role.")
return None
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user_id):
print(f"User {user_id} deleted!")
@require_role("editor")
def edit_post(post_id):
print(f"Post {post_id} edited!")
delete_user(42) # Access denied! You need 'admin' role.
edit_post(7) # Post 7 edited!
current_user_role would be injected (e.g, from a request context or a passed argument), not hardcoded. The hardcoded "editor" is just a placeholder to make the demo runnable.Same decorator, different configuration. That's the power of decorator arguments.
The Pattern to Remember
Without arguments - Two layers:
from functools import wraps
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
With arguments - Three layers:
from functools import wraps
def decorator_factory(arguments): # takes your settings
def decorator(func): # takes the function
@wraps(func)
def wrapper(*args, **kwargs): # replaces the function
return func(*args, **kwargs)
return wrapper
return decorator
The only difference is one extra outer function that holds your configuration. That's all there is to it.
Stacking Decorators
You can apply multiple decorators to a single function. They apply bottom-up: the bottom decorator wraps the function first, and the top decorator wraps the result.
@timer
@repeat(3)
def greet():
print("Hi!")
# Equivalent to:
decorated = repeat(3)(greet)
greet = timer(decorated)
Here, repeat(3) wraps greet first, then timer wraps the result. When you call greet(), timer's wrapper runs first, which calls repeat's wrapper, which calls the original greet three times.
The bottom decorator wraps first, but the top decorator's wrapper runs first when the function is called.
When Stacking Works Well
Decorators that transform return values compose beautifully together, because each decorator receives the output of the one below it and can modify it before passing it up:
from functools import wraps
def add_exclamation(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result + "!"
return wrapper
def make_uppercase(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@add_exclamation
@make_uppercase
def greet(name):
return f"hello, {name}"
print(greet("Moussa")) # HELLO, MOUSSA!
make_uppercase wraps first, turning the return value to "HELLO, MOUSSA".
Then add_exclamation wraps that, adding "!" to make "HELLO MOUSSA!".
Clean, predictable, composable.
When Stacking Gets Tricky
Decorators that use side effects (like print()) don't compose as cleanly. If one decorator prints something and another transforms return values, the printed output can't be intercepted or reordered by the other decorator. The side effects have already escaped the chain.
The general principle: When you're designing decorators that might be stacked, prefer working with return values over side effects. This gives other decorators in the chain a chance to interact with your output.
print()sends text directly to standard output and returnsNone, so another decorator cannot "catch" that printed text the way it can catch and modify a normal return value.
Common Decorator Patterns in the Wild
Now that you understand the mechanics, you'll start recognizing these patterns everywhere:
FastAPI routes
@app.get("/")
def root():
return {"message": "Welcome to my API"}
Django authentication
@login_required # simple decorator
def dashboard(request):
return render(request, "dashboard.html")
Python builtins
class MyClass:
@staticmethod # simple decorator
def utility():
pass
@property # simple decorator
def name(self):
return self._name
Functools caching
from functools import lru_cache
@lru_cache(maxsize=128) # decorator with arguments
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
They all follow the same principles we've learned. Some take arguments (three layers), some don't (two layers). But the core pattern is always the same: take a function in, return a wrapped version out.
What's Next?
You now have the complete picture of Python decorators, from the foundations through the common pitfalls to advanced patterns. In the final part of this series, I've put together 10 hands-on exercises that will test and solidify everything you've learned.
Subscribe to my newsletter if you don't want to miss any updates.
Thank you for reading 🙂.
This is Part 3 of my Python Decorator series. Check out Part 1: Understanding Decorators From Scratch for foundations and Part 2: The Print vs Return Trap for the most common pitfall.






