Troubleshooting Decorator Arguments vs Function Arguments

Decorators involve two separate streams of arguments that flow through different layers of nesting. Decorator arguments configure the decorator itself -- values like retry counts, log levels, or permission names. Function arguments are what callers pass when they invoke the decorated function. Mixing these up -- or losing track of which layer handles which -- is the single most common source of confusion when writing decorators. As PEP 318 established when decorator syntax was introduced in Python 2.4, a decorator is a function that takes the decorated function as a single argument and returns a callable. When decorator arguments enter the picture, an additional nesting layer becomes mandatory -- and that is where the confusion begins. This article maps each layer, explains every error that arises from mixing them, shows the patterns that keep them cleanly separated, and cites the official Python documentation so every claim is verifiable.

The Two Streams of Arguments

Every parameterized decorator involves two distinct sets of arguments that never mix. This separation is a direct consequence of how Python's @ syntax works. As the Python 2.4 What's New documentation explains, the @ syntax expects a callable that accepts one function and hands back another. When you need decorator arguments, the outer callable receives only those configuration values and must produce a second callable -- the actual decorator -- that then receives the target function.

Decorator arguments are values passed to the decorator at definition time. They configure how the decorator behaves. Examples: the 3 in @retry(3), the "admin" in @require_role("admin"), the level="DEBUG" in @log(level="DEBUG"). These values are captured in closures and available to all inner layers.

Function arguments are values passed by callers at call time. They are the arguments to the original function. Examples: the "Alice" in greet("Alice"), the 42 in fetch_user(42). These values flow through the innermost wrapper via *args and **kwargs.

The two streams never share a parameter list. They are handled by different layers of nesting, and every error in this article comes from accidentally putting one stream's arguments in the other stream's layer.

Two Layers: No Decorator Arguments

A decorator without its own arguments has two layers. The outer function receives the decorated function. The inner wrapper receives the caller's arguments:

import functools

# LAYER 1: Receives the function (runs once at definition time)
def timer(func):
    # LAYER 2: Receives the caller's arguments (runs every call)
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

@timer
def calculate(x, y):
    return x * y

calculate(6, 7)    # "calculate took 0.0000s"

There is no place for decorator arguments in this structure. func is not a decorator argument -- it is the function being decorated, passed automatically by Python's @ syntax. This two-layer pattern works when the decorator's behavior is fixed and does not need configuration. Note the use of @functools.wraps(func) on the wrapper -- this is essential for preserving the original function's __name__, __doc__, and other metadata. The Python functools documentation warns that omitting this decorator causes the wrapper to replace the original function's metadata -- meaning __name__ reflects the wrapper, not the function you wrote, and the docstring vanishes entirely.

Three Layers: With Decorator Arguments

When a decorator needs its own arguments, an additional outer layer is required. This outer layer captures the decorator arguments and returns the actual decorator:

import functools

# LAYER 1 (factory): Receives DECORATOR arguments (runs once at definition time)
def retry(max_tries=3, delay=1.0):

    # LAYER 2 (decorator): Receives the FUNCTION (runs once at definition time)
    def decorator(func):

        # LAYER 3 (wrapper): Receives FUNCTION arguments (runs every call)
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_tries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
            raise last_exc
        return wrapper
    return decorator

@retry(max_tries=5, delay=0.5)
def fetch_data(url):
    """Fetch data from a URL."""
    pass

fetch_data("https://example.com")

The three layers have clear, non-overlapping responsibilities. max_tries and delay are decorator arguments captured by Layer 1. func is the function being decorated, received by Layer 2. url is the caller's argument, received by Layer 3 through *args. The decorator arguments are available in Layers 2 and 3 through closures.

Note

The @retry(max_tries=5, delay=0.5) syntax calls retry(max_tries=5, delay=0.5) first, which returns decorator. Then Python applies decorator to fetch_data. This is equivalent to: fetch_data = retry(max_tries=5, delay=0.5)(fetch_data). The Python 2.4 What's New documentation describes the general rule: @A @B @C(args) on a function f becomes f = A(B(C(args)(f))).

Tracing Execution Through the Layers

The layering model becomes concrete when you trace what Python does, step by step, from the moment it encounters the @ syntax to the moment a caller invokes the decorated function. Walk through this with the retry decorator:

@retry(max_tries=5, delay=0.5)
def fetch_data(url):
    """Fetch data from a URL."""
    pass

fetch_data("https://example.com")

Step 1: Python evaluates retry(max_tries=5, delay=0.5). This calls the factory function. Inside, max_tries is bound to 5 and delay to 0.5. The factory defines decorator and wrapper as inner functions but does not execute them yet. It returns decorator. At this point, decorator is a closure that carries max_tries=5 and delay=0.5 in its enclosing scope.

Step 2: Python applies decorator to fetch_data. This calls decorator(fetch_data). Inside, func is bound to the original fetch_data function. The decorator applies @functools.wraps(func) to the wrapper, copying fetch_data's name and docstring. Then it returns wrapper. The name fetch_data in the module now points to wrapper -- not the original function.

Step 3: Someone calls fetch_data("https://example.com"). This calls wrapper("https://example.com"). Inside, args is ("https://example.com",). The wrapper accesses max_tries from its closure (value: 5) and loops up to 5 times. On each iteration, it calls func(*args, **kwargs) -- which invokes the original fetch_data("https://example.com").

The key insight: Steps 1 and 2 happen once, when the module loads. Step 3 happens every time someone calls fetch_data(). Decorator arguments are frozen into closures at Step 1 and remain accessible at Step 3. Function arguments exist only at Step 3. This is why accessing function arguments in Steps 1 or 2 is a NameError -- those values do not exist yet.

Mistake 1: Missing Parentheses

This is the most frequent error with parameterized decorators. Forgetting the parentheses passes the function directly to the factory as if it were a decorator argument:

# BROKEN -- missing parentheses
@retry                      # Python calls: retry(fetch_data)
def fetch_data(url):        # max_tries = fetch_data (a function object!)
    pass

fetch_data("https://example.com")
# TypeError or confusing error -- decorator received a function where it expected an int

Without parentheses, Python executes retry(fetch_data). The retry function assigns fetch_data to its max_tries parameter. Then it returns decorator as the result. fetch_data now points to decorator -- a function that expects a function argument, not a URL. When you call fetch_data("https://example.com"), Python calls decorator("https://example.com"), which crashes because a string is not a function.

The Fix

# CORRECT -- parentheses call the factory, which returns the decorator
@retry()                     # Uses defaults: max_tries=3, delay=1.0
def fetch_data(url):
    pass

# ALSO CORRECT -- with explicit arguments
@retry(max_tries=5, delay=0.5)
def fetch_data(url):
    pass
Warning

Even when using all default values, parameterized decorators still need parentheses: @retry(), not @retry. The parentheses call the factory. Without them, the factory never runs and the layers are misaligned.

The parentheses fix is the minimum solution. A more defensive approach is to add a type check in the factory that catches the mistake at decoration time with a clear error message, rather than letting it cascade into a confusing runtime failure:

# DEFENSIVE -- catch missing parentheses at decoration time
def retry(max_tries=3, delay=1.0):
    if callable(max_tries):
        raise TypeError(
            "Did you forget parentheses? Use @retry(), not @retry"
        )
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            ...
        return wrapper
    return decorator

This guard works because when parentheses are omitted, the decorated function (a callable) lands in max_tries. The callable() check detects this immediately and raises a message that points directly at the problem. Alternatively, the optional-arguments pattern covered later in this article eliminates the problem entirely by making parentheses optional. Choose the defensive guard when you want strict usage, and the optional-arguments pattern when you want flexibility.

Mistake 2: Adding Decorator Args to the Wrong Layer

This happens when developers try to add decorator arguments to a two-layer decorator by putting them on the outer function alongside func:

# BROKEN -- decorator argument on the same layer as func
def log(func, level="INFO"):     # Tries to receive both func and level
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[{level}] Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log(level="DEBUG")              # Python calls: log(level="DEBUG")
def process(data):               # func is never passed!
    pass
# TypeError: log() missing 1 required positional argument: 'func'

Python's @ syntax only supports two patterns: @decorator (passes the function to decorator) or @decorator(args) (calls decorator(args) first, then passes the function to the result). There is no syntax for passing both decorator arguments and the function at the same time. Decorator arguments and the function must live on separate layers.

The Fix

import functools

def log(level="INFO"):                   # Layer 1: decorator arg
    def decorator(func):                  # Layer 2: receives function
        @functools.wraps(func)
        def wrapper(*args, **kwargs):     # Layer 3: function args
            print(f"[{level}] Calling {func.__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@log(level="DEBUG")
def process(data):
    return len(data)

process([1, 2, 3])    # [DEBUG] Calling process

Mistake 3: Forwarding Decorator Args to the Function

This happens when the wrapper accidentally passes decorator arguments to the original function:

# BROKEN -- decorator argument leaked into the function call
def retry(max_tries=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_tries + 1):
                try:
                    return func(max_tries, *args, **kwargs)  # max_tries leaked!
                except Exception:
                    if attempt == max_tries:
                        raise
        return wrapper
    return decorator

@retry(max_tries=5)
def fetch(url):
    pass

fetch("https://example.com")
# TypeError: fetch() takes 1 positional argument but 2 were given

The wrapper calls func(max_tries, *args, **kwargs), injecting the decorator argument into the function's argument list. The function expects only url but receives 5 and "https://example.com". The decorator argument should be used inside the wrapper's logic, never passed to func().

The Fix

# CORRECT -- decorator argument used in logic, not in func() call
def wrapper(*args, **kwargs):
    for attempt in range(1, max_tries + 1):    # Use max_tries here
        try:
            return func(*args, **kwargs)        # Only caller's args
        except Exception:
            if attempt == max_tries:
                raise

The underlying principle: decorator arguments drive the wrapper's control flow (loops, conditionals, thresholds, formatting choices). They never enter the func() call. If you need to pass computed values to the original function, inject them through **kwargs explicitly and document the injection in the decorator's docstring, so callers know the function's signature has been extended:

# DELIBERATE INJECTION -- when you need to pass context to the function
def with_attempt_count(max_tries=3):
    """Injects 'attempt' as a keyword argument to the decorated function."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_tries + 1):
                try:
                    return func(*args, attempt=attempt, **kwargs)
                except Exception:
                    if attempt == max_tries:
                        raise
        return wrapper
    return decorator

@with_attempt_count(max_tries=3)
def fetch(url, attempt=1):    # 'attempt' is injected by the decorator
    print(f"Attempt {attempt} for {url}")

This is the one scenario where a decorator-controlled value enters func() -- but it is done through a named keyword that the function explicitly declares, not by silently prepending a positional argument. The function's signature reveals the injection. This is how libraries like Flask inject request context and how pytest fixtures deliver test dependencies.

Mistake 4: Accessing Function Args in the Wrong Layer

Function arguments are only available inside the wrapper (Layer 3). Trying to access them in Layer 1 or Layer 2 fails because those layers run at definition time, before any caller has invoked the function:

# BROKEN -- trying to validate function args in the decorator layer
def validate_positive(func):
    # This runs at DEFINITION TIME -- there are no args yet!
    if args[0] < 0:                   # NameError: args is not defined
        raise ValueError("Must be positive")
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# CORRECT -- validate inside the wrapper, which runs at CALL TIME
def validate_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if args[0] < 0:              # args is available here at call time
            raise ValueError("Must be positive")
        return func(*args, **kwargs)
    return wrapper
Pro Tip

The timing rule is simple: Layers 1 and 2 run once at definition time (when the @ syntax is processed). Layer 3 (the wrapper) runs every time the function is called. Anything that depends on caller-provided values must go inside the wrapper.

Check Your Understanding

Before moving on to the optional-arguments pattern, test whether the layering model has clicked. Each question targets a specific concept from the sections above. Click an answer to see feedback, and use the try again button to explore why each option is right or wrong.

Question 1 of 4
Given this decorator, what does fetch_data point to after the @ syntax runs?
def retry(max_tries=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            ...
        return wrapper
    return decorator

@retry(max_tries=5)
def fetch_data(url):
    pass
Question 2 of 4
What happens when you write @retry instead of @retry()?
Question 3 of 4
In a three-layer parameterized decorator, which layer runs every time the decorated function is called?
Question 4 of 4
Why can't you validate function arguments inside Layer 2 (the decorator)?

The Optional-Arguments Pattern

Python's @dataclass works both as @dataclass and @dataclass(frozen=True). As documented in PEP 557, the @dataclass decorator examines the class to find fields and can accept configuration parameters. You can implement the same flexibility in your own decorators using the optional-arguments pattern. The trick is detecting whether the first argument is the function itself (no parentheses) or a decorator argument (with parentheses):

import functools

def log(func=None, *, level="INFO"):
    """Works as @log, @log(), or @log(level="DEBUG")."""
    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            print(f"[{level}] Calling {f.__name__}")
            return f(*args, **kwargs)
        return wrapper

    if func is not None:
        # Called as @log (no parentheses) -- func is the function
        return decorator(func)
    # Called as @log() or @log(level="DEBUG") -- func is None
    return decorator

# All three usages work:
@log
def process_a(data):
    return data

@log()
def process_b(data):
    return data

@log(level="DEBUG")
def process_c(data):
    return data

process_a([1])    # [INFO] Calling process_a
process_b([2])    # [INFO] Calling process_b
process_c([3])    # [DEBUG] Calling process_c

The * in the parameter list forces all arguments after func to be keyword-only. This prevents callers from accidentally passing a decorator argument as the function. When @log is used without parentheses, Python calls log(process_a) -- func receives the function, and the decorator is applied immediately. When @log(level="DEBUG") is used, Python calls log(level="DEBUG") -- func is None, and the function returns decorator for later application.

Note

This pattern relies on the fact that a bare @log passes a callable as the first positional argument, while @log(level="DEBUG") passes a string keyword argument. The * separator guarantees that decorator arguments can never be confused with the function, because only func can be positional. Miguel Grinberg describes this as a hack in his decorator tutorial: the decorator checks whether a single callable argument was passed and assumes it was called without arguments in that case.

Stacking Parameterized Decorators

When multiple parameterized decorators are stacked on a single function, each one creates its own set of layers. The key question is: in what order do the wrappers execute? Consider this example:

import functools

def log(level="INFO"):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] Calling {func.__name__}")
            result = func(*args, **kwargs)
            print(f"[{level}] {func.__name__} returned {result}")
            return result
        return wrapper
    return decorator

def validate(min_val=0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if args[0] < min_val:
                raise ValueError(f"First arg must be >= {min_val}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@log(level="DEBUG")
@validate(min_val=1)
def square(n):
    return n * n

square(4)

Python applies stacked decorators from bottom to top. The @validate(min_val=1) decorator wraps square first, producing a wrapper that validates the argument. Then @log(level="DEBUG") wraps that result, producing an outer wrapper that logs the call. The desugared equivalent is: square = log(level="DEBUG")(validate(min_val=1)(square)).

When someone calls square(4), execution flows from the outermost wrapper inward. The log wrapper prints [DEBUG] Calling square, then calls func(4) -- which is the validate wrapper. The validate wrapper checks that 4 >= 1, then calls its own func(4) -- which is the original square. The original returns 16, which bubbles back through both wrappers. The application order (bottom-to-top at definition) is the reverse of the execution order (top-to-bottom at call time).

Pro Tip

Think of stacked decorators like wrapping a gift in layers of paper. The last decorator applied (@log) is the outermost layer. When you unwrap (call the function), you go through the outermost layer first. Each wrapper's func points to the next wrapper inward, not the original function -- unless it is the innermost decorator. This is why @functools.wraps(func) matters on every layer: without it, func.__name__ in the log wrapper would show the validate wrapper's name instead of "square".

Why functools.wraps Matters at Every Layer

Every code example in this article uses @functools.wraps(func) on the wrapper function, and this is not optional boilerplate. Without it, the decorated function loses its identity. As documented in the functools module reference, this decorator exists specifically to transfer key attributes from the original function to its replacement, preventing silent metadata loss that breaks downstream tooling.

The consequences are concrete. When you omit @functools.wraps(func), the decorated function's __name__ becomes "wrapper", its __doc__ becomes None (or the wrapper's docstring), and its __module__, __qualname__, and __annotations__ all reflect the wrapper instead of the original function. This breaks help(), misleads debuggers and logging, prevents serialization with pickle, and confuses documentation generators like Sphinx.

import functools

def retry(max_tries=3):
    def decorator(func):
        @functools.wraps(func)       # Preserves func's identity
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_tries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
            raise last_exc
        return wrapper
    return decorator

@retry(max_tries=5)
def fetch_data(url):
    """Fetch data from a URL with automatic retries."""
    pass

# With @functools.wraps(func):
print(fetch_data.__name__)    # "fetch_data"
print(fetch_data.__doc__)     # "Fetch data from a URL with automatic retries."

# Without @functools.wraps(func):
# print(fetch_data.__name__)  # "wrapper"
# print(fetch_data.__doc__)   # None

The functools.wraps decorator also adds a __wrapped__ attribute to the wrapper, which holds a reference to the original function. This enables introspection tools to access the unwrapped function when needed. As Graham Dumpleton explains in the wrapt library documentation, writing manual attribute-copying code for every decorator is tedious and fragile -- functools.wraps was added to the standard library specifically to solve this problem. In parameterized decorators, @functools.wraps(func) always goes on the innermost wrapper (Layer 3), because that is the function that replaces the original.

Spot the Bug

Each of the following decorators has a single bug that will cause an error or incorrect behavior. Read the code carefully, then select the answer that correctly identifies the problem. Use the try again button to read the feedback for every option -- understanding why each wrong answer is wrong strengthens your mental model as much as getting it right.

Challenge 1
This parameterized decorator is supposed to measure execution time and print a custom label. Where is the bug?
import functools, time

def timed(label="Timer"):
    def decorator(func):
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            print(f"[{label}] {func.__name__}: {time.perf_counter() - start:.4f}s")
            return result
        return wrapper
    return decorator

@timed(label="DB")
def query_users(limit):
    """Fetch users from database."""
    return ["alice", "bob"][:limit]
Challenge 2
This decorator is supposed to limit how many times a function can be called. It crashes at runtime. Where is the bug?
import functools

def limit_calls(max_calls=5):
    count = 0
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if count >= max_calls:
                raise RuntimeError(f"{func.__name__} exceeded {max_calls} calls")
            count += 1
            return func(*args, **kwargs)
        return wrapper
    return decorator

@limit_calls(max_calls=3)
def send_email(to):
    print(f"Sending to {to}")

send_email("alice@example.com")

Layer Map Reference

LayerReceivesRuns WhenHas Access Tofunctools.wraps
1 (Factory)Decorator arguments (max_tries, level)Definition time (once)Decorator arguments onlyNot applicable
2 (Decorator)The function being decorated (func)Definition time (once)Decorator arguments + funcNot applicable
3 (Wrapper)Caller's arguments (*args, **kwargs)Every callDecorator arguments + func + caller's argsApplied here: @functools.wraps(func)
ReceivesDecorator arguments (max_tries, level)
Runs WhenDefinition time (once)
Has Access ToDecorator arguments only
functools.wrapsNot applicable
ReceivesThe function being decorated (func)
Runs WhenDefinition time (once)
Has Access ToDecorator arguments + func
functools.wrapsNot applicable
ReceivesCaller's arguments (*args, **kwargs)
Runs WhenEvery call
Has Access ToDecorator arguments + func + caller's args
functools.wrapsApplied here: @functools.wraps(func)

Without decorator arguments, Layer 1 does not exist. The decorator is Layer 2 and the wrapper is Layer 3 (renumbered as Layers 1 and 2 in a two-layer decorator). The rule is always: decorator arguments go in the outermost layer, the function goes in the next layer, and caller's arguments go in the innermost layer.

Key Takeaways

  1. Decorator arguments and function arguments are two separate streams. Decorator arguments configure behavior at definition time. Function arguments are caller-provided values at call time. They are handled by different layers and should never share a parameter list.
  2. Two layers when the decorator has no arguments; three layers when it does. The additional outer layer (the factory) captures decorator arguments and returns the actual decorator. The factory is called by the parentheses in @retry(3).
  3. Parentheses are always required on parameterized decorators. Even with all-default values, write @retry(), not @retry. Missing parentheses pass the function to the factory as a decorator argument, misaligning every layer.
  4. Never put decorator arguments on the same layer as func. Python's @ syntax does not support passing decorator arguments and the function in the same call. They must be on separate nesting layers.
  5. Never forward decorator arguments to func(). Use decorator arguments in the wrapper's logic (loops, conditionals, formatting). Only forward *args and **kwargs to func().
  6. Function arguments only exist at call time. Layers 1 and 2 run at definition time, before any caller has invoked the function. Validation, transformation, or inspection of function arguments must happen inside the wrapper (Layer 3).
  7. The optional-arguments pattern eliminates the parentheses problem. Using func=None with keyword-only arguments lets a decorator work as @log, @log(), and @log(level="DEBUG") with a single implementation.
  8. Always apply @functools.wraps(func) to the wrapper. Without it, the decorated function loses its __name__, __doc__, __module__, __qualname__, and __annotations__. This breaks help(), debuggers, serializers, and documentation generators. It always goes on Layer 3 (the innermost wrapper).

The layering model is the one concept that makes every decorator pattern predictable. Once you can identify which layer receives decorator arguments, which receives the function, and which receives the caller's arguments, the nesting stops being confusing and becomes mechanical. Every troubleshooting scenario in this article -- missing parentheses, wrong-layer arguments, leaked decorator arguments, wrong-time access -- maps directly to a violation of the layering rules.

Sources and Further Reading