Decorator with Arguments in Python: Complete Advanced Function Wrapping and Customization Guide

Decorator with arguments in python

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:

  1. retry(times=3) executes immediately and returns decorator.
  2. decorator(my_func) executes and returns wrapper.
  3. The name my_func in the module namespace is rebound to wrapper.

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 RateLimiter example 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.wraps inside 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.

References

Total
0
Shares

Leave a Reply

Previous Post
Decorator class in python

Decorator Class in Python: Complete Callable Object and Function Enhancement Implementation Guide

Next Post
Create singleton class with a decorator in python

Create Singleton Class with a Decorator in Python: Complete Design Pattern Implementation Guide

Related Posts