You've probably used decorators before, @login_required, @cache, @app.route("/").
They feel like magic: one line above a function and its behavior changes entirely. But at some point you need more than an on/off switch. You need @retry(times=3), @cache(maxsize=128), @require_role("admin"). That's when most developers hit a wall.
The good news is that parameterized decorators aren't a different concept, they're just one extra function layer on top of what you already know. Once it clicks, you'll see the pattern everywhere in the Python ecosystem and start reaching for it naturally in your own code.
In this tutorial you'll go from a plain decorator to a fully parameterized one, understand exactly why the three-layer structure is necessary, and build a handful of decorators you could drop into a real project today, including a @retry decorator that handles flaky network calls and a @require_role guard for protecting API endpoints.
Let's start with a quick recap of how decorators work under the hood, because that foundation is what makes everything else make sense.
Decorators Under the Hood
If you've written a decorator before, you know the syntax.
But let's slow down and look at what Python is actually doing, because that mental model is the key to understanding parameterized decorators later.
Here's the simplest possible decorator:
def shout(fn):
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
return str(result).upper()
return wrapper
@shout
def greet(name):
"""Greet the user"""
return f"hello, {name}"
print(greet("alice")) # "HELLO, ALICE"
The @shout syntax is pure shorthand. Python translates it into exactly this:
greet = shout(greet)
That's the whole trick.
shout receives the original function, returns a new one (wrapper), and Python rebinds the name greet to point at wrapper from that point on.
Every time you call greet(...), you're actually calling wrapper(...), which calls the original function internally.
Visually, the transformation looks like this:
The identity problem
There's a subtle issue with the above. After decoration, greet is no longer quite itself:
print(greet.__name__) # "wrapper" ← wrong
print(greet.__doc__) # None ← lost
Python sees wrapper where it expects greet. This breaks help(), logging, stack traces, and any tool that inspects function metadata.
The fix is functools.wraps:
import functools
def shout(fn):
@functools.wraps(fn) # copies __name__, __doc__, __annotations__, __wrapped__
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
return str(result).upper()
return wrapper
@shout
def greet(name):
"""Greet the user"""
return f"hello, {name}"
print(greet.__name__) # "greet"
print(greet.__doc__) # "Greet the user"
Now greet.__name__ is "greet" and greet.__wrapped__ holds a reference to the original function, useful for testing, which you'll see later.
Think of @functools.wraps as the professional finish on every decorator you write. It costs one line and saves a lot of confusion.
With that foundation in place, it's time to add parameters and that's where things get genuinely interesting.
All the code of this and the other examples in this article are available at: https://github.com/nunombispo/python-decorators-article
The Three Layers of a Parametrized Decorator
A plain decorator takes a function and returns a function. That's one layer.
The moment you add parameters, you need somewhere to put them and that somewhere is a new outer function that runs before the decorator does.
That gives you three layers total.
Here's the structure, stripped to its bones:
def outer(param): # layer 1: receives your arguments
def decorator(fn): # layer 2: receives the function
def wrapper(*args, **kwargs): # layer 3: runs on every call
# param is available here
return fn(*args, **kwargs)
return wrapper
return decorator
When Python sees @outer(param), it does this in two steps:
decorator = outer(param) # step 1: call the factory, get a decorator back
greet = decorator(greet) # step 2: apply that decorator to the function
Which collapses to the familiar one-liner:
greet = outer(param)(greet)
The diagram maps this exactly:
Why the extra layer exists
A plain @decorator works because Python calls it with the function as its only argument.
But @outer(param) is evaluated before Python passes the function, outer(param) runs immediately and must return something callable that accepts a function. That's decorator.
You're not adding complexity for its own sake; the structure is forced by the order Python evaluates things.
Closure capture
The reason param is available inside wrapper, despite outer having already returned, is closures.
When wrapper is defined inside decorator, which is defined inside outer, it captures references to variables in the enclosing scopes. param doesn't get copied into wrapper; it stays alive because wrapper holds a reference to it.
If you want a deeper understanding of how Python resolves variable names across nested scopes, check out A Practical Guide to Python Variable Scope - it covers the LEGB rule and closures in detail and will help you understand it better.
This is what makes the pattern so powerful. You can pass in times=3 or role="admin" at decoration time, and that value is quietly available every single time the wrapped function is called, no global state, no configuration objects, just a variable captured in a closure.
A concrete way to see this:
def outer(param):
print(f"outer called with {param}") # runs once, at decoration time
def decorator(fn):
print(f"decorator called with {fn}") # runs once, at decoration time
def wrapper(*args, **kwargs):
print(f"wrapper called, param={param}") # runs on every call
return fn(*args, **kwargs)
return wrapper
return decorator
@outer("hello")
def greet(name):
return name
Running this, you'll see outer and decorator print immediately when the module loads. wrapper only prints when you actually call greet(...).
That timing distinction, decoration time vs call time, is one of the most common sources of bugs when writing parameterized decorators.
With the structure clear, let's build something real with it.
Building Your First Parameterized Decorator
Time to put the three-layer pattern to work.
@repeat is the perfect first example - the logic is simple enough that the decorator structure stays front and center.
Here's the goal:
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
And here's the full implementation:
import functools
def repeat(times): # layer 1: factory
def decorator(fn): # layer 2: decorator
@functools.wraps(fn)
def wrapper(*args, **kwargs): # layer 3: wrapper
for _ in range(times):
result = fn(*args, **kwargs)
return result
return wrapper
return decorator
Let's walk through each layer.
Layer 1 - repeat(times)
This is the factory. It runs exactly once, at decoration time, when Python evaluates @repeat(times=3). Its only job is to receive the argument and return a decorator. After this call, times is captured in the closure and available to everything nested inside.
Layer 2 - decorator(fn)
This is what Python passes the function to. It also runs once, at decoration time, immediately after repeat(times) returns. It receives greet as fn, applies @functools.wraps(fn) to preserve its identity, and returns wrapper as the replacement.
Layer 3 - wrapper(*args, **kwargs)
This is the function your code actually calls every time you write greet("Alice"). It has access to both times (from layer 1's closure) and fn (from layer 2's closure). The loop runs, the original function is called each iteration, and the last result is returned.
A note on return value
Storing result and returning it after the loop means the caller always gets the return value of the last call. For a @repeat decorator that's fine - but in other decorators, returning inside the loop on the first successful call is often the right choice. Always think about what your wrapper should hand back to the caller.
Checking it works
Thanks to functools.wraps, the function's identity is intact:
print(greet.__name__) # "greet"
print(greet.__wrapped__) # <function greet at 0x...>
And you can access the original unwrapped function via __wrapped__ - handy when testing, since you can call greet.__wrapped__("Alice") to bypass the decorator entirely.
Decorators You'll Actually Ship
@repeat is a clean teaching example, but the pattern really earns its keep when the logic inside wrapper does something meaningful.
Here are three parameterized decorators that solve real problems.
@retry - handling flaky operations
Network calls fail. APIs rate-limit. Database connections drop.
A @retry decorator lets you express resilience in one line at the call site rather than cluttering every function with try/except loops.
import time
import functools
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
print(f"Attempt {attempt} of {times}")
try:
return fn(*args, **kwargs)
except exceptions as e:
last_exc = e
if attempt < times:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(times=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
... # flaky network call
A few design decisions worth noting.
The exceptions parameter lets the caller decide what counts as a retry, you never want to silently swallow a ValueError or KeyError that signals a bug in your own code.
Storing last_exc and re-raising it after the loop preserves the original trace-back rather than replacing it with a generic message.
And returning immediately on success means you don't pay any extra overhead on the happy path.
@timer - measuring execution time
Useful during development and in production monitoring.
The unit parameter keeps the output readable whether you're measuring a 2-ms database query or a 45-second batch job.
import time
import functools
def timer(unit="ms"):
units = {"ms": 1_000, "s": 1, "us": 1_000_000}
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = (time.perf_counter() - start) * units[unit]
print(f"{fn.__name__} took {elapsed:.2f}{unit}")
return result
return wrapper
return decorator
@timer(unit="ms")
def query_database(sql):
...
@timer(unit="s")
def generate_report():
...
time.perf_counter() is the right choice here - it's the highest-resolution clock available and unaffected by system clock adjustments.
Notice that result is captured before printing, so the timing includes only the function itself, not the print call.
@require_role - protecting endpoints
A common pattern in Django, Flask, and FastAPI: restrict a view or route handler to users with a specific role, without repeating the same guard logic in every function body.
import functools
from functools import wraps
class User:
def __init__(self, name, role):
self.name = name
self.role = role
def require_role(role):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
# in a real app, current_user comes from your auth context
user = kwargs.get("user") or (args[0] if args else None)
if user is None or user.role != role:
raise PermissionError(f"Role '{role}' required.")
return fn(*args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user, target_id):
...
@require_role("editor")
def publish_post(user, post_id):
...
The role string is captured in the closure at decoration time, so delete_user and publish_post each carry their own independent requirement - no shared state, no configuration lookup at runtime.
In a real application you'd pull the current user from a request context or session rather than a function argument, but the decorator structure stays identical.
Stacking Decorators
Nothing stops you from applying multiple decorators to the same function.
In fact, combining small focused decorators is one of the cleanest ways to compose behavior in Python:
@retry(times=3, delay=0.5)
@timer(unit="ms")
def fetch_data(url):
...
But the order matters, and it trips people up.
Application order is bottom-up
Python applies decorators from the one closest to def outward. The equivalent explicit form makes this concrete:
fetch_data = retry(times=3, delay=0.5)(timer(unit="ms")(fetch_data))
timer wraps fetch_data first, then retry wraps the result of that. So at runtime, calling fetch_data(url) enters retry's wrapper first, which calls timer's wrapper, which calls the original function.
A useful mental model: read the decorators from top to bottom to understand the call order at runtime - the topmost decorator is entered first.
@retry ← entered first at runtime, sees everything below
@timer ← entered second def fetch_data
In this arrangement, @timer measures the total time including all retry attempts:
@timer(unit="ms") ← entered first, measures total time including all retries
@retry(times=3) ← retries happen inside the timed block
def fetch_data(url):
...
Swap the order and it measures only a single attempt:
@retry(times=3) ← entered first, retries everything below
@timer(unit="ms") ← measures a single attempt
def fetch_data(url):
...
Neither is wrong - they measure different things. Being deliberate about order is part of using stacked decorators well.
Debugging stacked decorators
When something goes wrong in a stack, the first tool to reach for is __wrapped__. Because functools.wraps sets this attribute on every wrapper, you can peel back the layers manually:
fetch_data # retry's wrapper
fetch_data.__wrapped__ # timer's wrapper
fetch_data.__wrapped__.__wrapped__ # original fetch_data
If one of your decorators is missing functools.wraps, the chain breaks at that point - __wrapped__ won't exist and you can't see past it. This is the most common reason to go back and add @functools.wraps(fn) to an older decorator you didn't write yourself.
For a quicker inspection, inspect.unwrap() does the peeling for you:
import inspect
original = inspect.unwrap(fetch_data)
print(original) # <function fetch_data at 0x...>
It follows __wrapped__ all the way down to the original function in one call. Pair it with inspect.signature() to confirm the original signature is intact:
print(inspect.signature(fetch_data)) # (*args, **kwargs) if wraps is missing
print(inspect.signature(inspect.unwrap(fetch_data))) # (url) ← correct
A mangled signature is usually the first sign that a decorator in the stack is missing functools.wraps.
Once your decorator is solid, it's worth automating those checks - GitHub Actions for Python Projects walks you through running tests, linting, and type checks on every push with a single YAML file.
Conclusion
Parametrized decorators look intimidating at first glance, but the pattern is simpler than it appears: a factory captures your arguments, returns a decorator, which wraps your function in a closure that keeps everything in scope.
That three-layer structure is all there is to it - and once it clicks, you'll spot it everywhere. Django's @permission_required, FastAPI's @app.get("/"), Tenacity's @retry - they're all the same pattern dressed for different jobs.
Three things worth carrying forward.
First, always apply @functools.wraps - it costs one line and keeps __name__, __doc__, and __wrapped__ intact, which matters the moment you start debugging or writing tests.
Second, be deliberate about stacking order; the topmost decorator is entered first at runtime, so it sees everything below it.
Third, keep the decoration-time vs call-time distinction in mind - the factory and decorator layers run once when the module loads, wrapper runs on every call.
Mixing those two up is the most common source of subtle bugs when writing parameterized decorators.
If you find yourself copying @retry or @timer into every new project, that's a sign they've earned their own package - How to Build and Publish a Python Package to PyPI walks you through the whole process with a real project from start to finish.
Follow me on Twitter: https://twitter.com/DevAsService
Follow me on Instagram: https://www.instagram.com/devasservice/
Follow me on TikTok: https://www.tiktok.com/@devasservice
Follow me on YouTube: https://www.youtube.com/@DevAsService


Top comments (0)