When I first learned decorators in Python, I thought I had it all figured out. I could slap an @my_decorator on top of a function and feel like a wizard. Then I ran into a wall: what if I wanted my decorator itself to accept arguments? Like @retry(times=3) or @log(level="debug"). That’s when I realized a regular decorator and a decorator with arguments are two completely different beasts, and the difference comes down to one extra layer of function nesting.
In this guide, I’m going to walk you through everything I’ve learned about building decorators that accept arguments — from the basic mental model to the internal mechanics, performance considerations, and real production patterns I actually use.
What a Decorator With Arguments Really Is
A plain decorator is a function that takes a function and returns a function. A decorator with arguments is a function that takes arguments and returns a decorator. That’s the whole trick — you’re adding one more level of indirection.
Here’s the skeleton I always start with:
def decorator_factory(arg1, arg2):
def actual_decorator(func):
def wrapper(*args, **kwargs):
# use arg1, arg2 here
return func(*args, **kwargs)
return wrapper
return actual_decorator
When you write @decorator_factory(10, 20) above a function, Python first calls decorator_factory(10, 20), which returns actual_decorator. Then Python applies actual_decorator to your function, just like a normal decorator. This is the part that confused me for weeks until I sat down and traced through it manually.
A Practical Example: Retry Logic
Let’s build something I actually use in real projects — a retry decorator that takes the number of attempts as an argument.
import time
import functools
def retry(times=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"Attempt {attempt} failed: {e}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=3, delay=0.5)
def unstable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API timeout")
return "Success"
print(unstable_api_call())
Output (varies by run):
Attempt 1 failed: API timeout
Attempt 2 failed: API timeout
Success
I use functools.wraps here deliberately — without it, unstable_api_call.__name__ would return "wrapper" instead of "unstable_api_call", which breaks introspection, documentation tools, and debugging. This is one of the most common mistakes I see in decorator code, including my own early scripts.
The Internal Working: What Python Is Actually Doing
Understanding decorators at the syntactic sugar level helped me stop treating them as magic. This:
@retry(times=3)
def my_func():
pass
is exactly equivalent to:
def my_func():
pass
my_func = retry(times=3)(my_func)
Three things happen in sequence:
retry(times=3)executes immediately and returnsdecorator.decorator(my_func)executes and returnswrapper.- The name
my_funcin the module namespace is rebound towrapper.
This means the closures capture times and delay from the outer retry call, and func from the decorator call. Each of these is stored in the __closure__ attribute of wrapper, which you can actually inspect:
print(wrapper.__closure__)
This gave me a much deeper appreciation for how Python’s closures work under the hood — each nested function keeps a reference to the free variables of its enclosing scope via cell objects.
Making Decorators Work With or Without Arguments
One pattern I frequently need in my own libraries is a decorator that works both as @my_decorator and @my_decorator(arg=1). This requires checking whether the first argument is callable:
import functools
def smart_decorator(func=None, *, prefix="LOG"):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
print(f"[{prefix}] Calling {f.__name__}")
return f(*args, **kwargs)
return wrapper
if func is not None:
return decorator(func)
return decorator
@smart_decorator
def greet():
print("Hello")
@smart_decorator(prefix="DEBUG")
def farewell():
print("Bye")
greet()
farewell()
Output:
[LOG] Calling greet
Hello
[DEBUG] Calling farewell
Bye
I picked up this pattern from reading the source of popular libraries like Click and pytest, and it’s saved me from writing two separate decorators for the same behavior.
Class-Based Decorators With Arguments
Functions aren’t the only way to build parameterized decorators. I sometimes prefer classes when the decorator needs to maintain state across calls:
class RateLimiter:
def __init__(self, calls_per_second):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - self.last_call
wait_time = self.min_interval - elapsed
if wait_time > 0:
time.sleep(wait_time)
self.last_call = time.time()
return func(*args, **kwargs)
return wrapper
@RateLimiter(calls_per_second=2)
def fetch_data():
print("Fetching...")
for _ in range(3):
fetch_data()
Here, __init__ receives the decorator’s arguments and __call__ receives the function being decorated. This structure mirrors the two-level nesting of the function-based approach but is often easier to read when you have multiple pieces of state to track.
Stacking Multiple Parameterized Decorators
Decorators apply bottom-up. If I write:
@retry(times=2)
@smart_decorator(prefix="TRACE")
def process():
print("Processing")
Python first wraps process with smart_decorator, then wraps that result with retry. So the call order at runtime is: retry‘s wrapper runs first, which calls smart_decorator‘s wrapper, which calls the original process. Getting this order backwards is a mistake I made more than once early on, especially when combining logging and caching decorators where order genuinely changes behavior.
Performance Considerations
Every layer of wrapping adds a function call to the stack. For most applications this overhead is negligible — we’re talking nanoseconds — but if you’re decorating a function that’s called millions of times in a tight loop (like inside NumPy-style numerical code), the extra call frames do add measurable overhead. I’ve profiled this using timeit:
import timeit
def plain(x):
return x + 1
@retry(times=1)
def decorated(x):
return x + 1
print(timeit.timeit(lambda: plain(5), number=1000000))
print(timeit.timeit(lambda: decorated(5), number=1000000))
In my tests, the decorated version was roughly 1.5–2x slower purely due to the extra call indirection and the try/except machinery. That’s fine for API handlers or CLI tools, but I’d avoid heavy decorators inside numeric hot loops.
Real-World Use Cases
Some of the places I’ve used parameterized decorators professionally:
- Authentication and permissions:
@require_role("admin")on Flask or FastAPI view functions. - Caching with custom TTL:
@cache(ttl=300)for expensive database queries. - Input validation:
@validate_schema(MySchema)to check request payloads. - Rate limiting: exactly like the
RateLimiterexample above, used on external API wrappers. - Feature flags:
@feature_flag("new_checkout")to gate code paths in production.
Best Practices and Common Mistakes
A few lessons I’ve internalized the hard way:
- Always use
functools.wrapsinside the innermost wrapper — forgetting it silently breaks__name__,__doc__, and tools that rely on introspection like Sphinx or pytest fixtures. - Keep the decorator factory (outer function) free of side effects; it can be called at import time, which is not always when you expect.
- Don’t mutate mutable default arguments inside the decorator factory’s signature.
- When debugging, print or log inside each layer temporarily to confirm the order of execution — it clarified more bugs for me than staring at the code ever did.
- Avoid over-nesting decorators for the sake of “cleverness.” If a decorator needs four arguments and four levels of nested functions, a class-based approach is usually more readable.
Troubleshooting Tips
If your decorated function throws TypeError: decorator() takes 1 positional argument but 2 were given, you’ve likely forgotten the outer factory layer — you defined a regular decorator but tried to call it with arguments.
If __name__ shows wrapper instead of your function’s real name, add @functools.wraps(func).
If closures seem to capture the wrong value (common in loops), remember that closures capture variables, not values, at call time — use default arguments to capture the current value if needed.
FAQs
Can a decorator have both positional and keyword arguments? Yes. The outer factory function can accept any combination of *args and **kwargs, exactly like any other Python function.
Is functools.wraps mandatory? Not mandatory, but strongly recommended for any decorator meant for real-world or shared code.
Can I combine argument-based decorators with class decorators? Yes — you can decorate a class definition itself using a parameterized decorator that modifies or wraps the class.
Do parameterized decorators affect stack traces? Slightly. You’ll see extra frames from wrapper in tracebacks, but functools.wraps at least keeps the function name accurate.
Summary
Decorators with arguments are just decorators wrapped inside a factory function — one extra layer that lets you customize behavior at decoration time. Once you internalize the three-step unwrapping (factory(args) → decorator(func) → wrapper(*args, **kwargs)), the pattern becomes second nature. I use this technique constantly for retries, rate limiting, caching, and access control, and understanding the internals has made debugging decorator-heavy codebases far less mysterious.