A standard decorator accepts exactly one argument: the function it decorates. There is no room in that signature for your own parameters. When you need to control a decorator's behavior at the point of application -- specifying how many times to retry, which log level to use, or what role to require -- you need a different structure. Python provides three approaches: the three-level nested function pattern (decorator factory), class-based decorators, and the functools.partial shortcut. This article walks through each one with complete, runnable code.
The core challenge is that Python's decorator machinery always passes the decorated function as a single positional argument to whatever callable follows the @ symbol. If you want your own arguments to reach the decorator, you need an intermediate step: a function that accepts your arguments first and then returns a decorator that Python can apply normally. The syntax for this is precise, and getting it wrong produces errors that are difficult to diagnose without understanding the underlying mechanism.
Why Standard Decorators Cannot Accept Parameters
A standard decorator is a function that takes one argument (the function to decorate) and returns one value (the wrapper). Python's @ syntax calls the decorator with the function as its only argument. There is no mechanism in this call for additional arguments:
from functools import wraps
def log_calls(func):
"""A standard decorator. Accepts only 'func'. No room for parameters."""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
return f"Hello, {name}"
# Python translates @log_calls to: greet = log_calls(greet)
# The ONLY argument log_calls receives is greet.
print(greet("Kandi"))
# [LOG] Calling greet
# Hello, Kandi
If you wanted to control the log level -- say, choosing between "INFO" and "DEBUG" -- there is nowhere to pass that value. Writing @log_calls("DEBUG") would pass the string "DEBUG" as the func argument instead of a function, which would fail when the decorator tries to wrap it. The solution is to add an outer layer that captures your parameters before the decorator receives the function.
@log_calls("DEBUG") on a standard decorator that only accepts func?@log_calls("DEBUG"), Python evaluates log_calls("DEBUG") immediately. Since log_calls only expects func, the string "DEBUG" is received as the func parameter. The decorator then tries to call @wraps("DEBUG") and wrap a string, which fails with a TypeError.from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# This calls log_calls("DEBUG")
# func receives "DEBUG" (a string, not a function)
# Then Python tries to apply the return value to greet
# Result: TypeError
# @log_calls("DEBUG") # TypeError
# def greet(name):
# return f"Hello, {name}"
log_calls("DEBUG") as a normal function call, passing "DEBUG" as the first positional argument. Since the decorator expects a function, not a string, this causes a TypeError. Python never silently discards arguments in decorator calls.# Python evaluates @expression then calls the result with func
# Step 1: result = log_calls("DEBUG") -> func="DEBUG"
# Step 2: greet = result(greet) -> fails
# The string "DEBUG" is NOT ignored.
# It is treated as the func argument.
@log_calls("DEBUG") is valid Python syntax. The @ symbol accepts any expression that evaluates to a callable. The error occurs at runtime, not at parse time. Python calls log_calls("DEBUG"), which passes the string as func, and the resulting TypeError is a runtime exception.# The @ syntax accepts any expression:
# @some_function() valid syntax
# @some_function("arg") valid syntax
# @obj.method valid syntax
# @dict["key"] valid syntax
# The error is a runtime TypeError, not a SyntaxError.
# Python does not validate what the decorator expression
# returns until it tries to call it with the function.
The Three-Level Pattern (Decorator Factory)
The standard approach for parameterized decorators uses three levels of nested functions. The outermost function (the factory) accepts your custom parameters. It returns a middle function (the decorator) that accepts the function to be decorated. The decorator returns an inner function (the wrapper) that runs each time the decorated function is called:
from functools import wraps
def log_calls(level="INFO"): # Level 1: FACTORY — accepts YOUR parameters
def decorator(func): # Level 2: DECORATOR — accepts the function
@wraps(func)
def wrapper(*args, **kwargs): # Level 3: WRAPPER — runs on each call
print(f"[{level}] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[{level}] {func.__name__} returned {result!r}")
return result
return wrapper
return decorator
@log_calls(level="DEBUG")
def add(a, b):
return a + b
@log_calls(level="WARNING")
def divide(a, b):
return a / b
print(add(3, 7))
# [DEBUG] Calling add
# [DEBUG] add returned 10
# 10
print(divide(10, 3))
# [WARNING] Calling divide
# [WARNING] divide returned 3.3333333333333335
# 3.3333333333333335
When Python encounters @log_calls(level="DEBUG"), it evaluates log_calls(level="DEBUG") first. This calls the factory, which returns the decorator function with level="DEBUG" captured in its closure. Python then calls decorator(add), which returns the wrapper function. The name add is rebound to the wrapper.
The manual equivalent makes the two-step process explicit:
# What Python does behind the scenes:
# Step 1: Call the factory with your parameters
decorator = log_calls(level="DEBUG")
# Step 2: Call the returned decorator with the function
add = decorator(add)
# Combined into one line:
# add = log_calls(level="DEBUG")(add)
The three levels exist because each one has a separate job: capture parameters, capture the function, and execute the wrapper logic. Removing any level eliminates one of these capabilities.
A Real-World Example: Retry with Configurable Attempts
import time
from functools import wraps
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""Factory: accepts retry configuration."""
def decorator(func):
"""Decorator: accepts the function."""
@wraps(func)
def wrapper(*args, **kwargs):
"""Wrapper: runs on each call with retry logic."""
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_error = e
if attempt < max_attempts:
print(f" Attempt {attempt}/{max_attempts} failed: "
f"{e}. Retrying in {delay}s...")
time.sleep(delay)
raise last_error
return wrapper
return decorator
call_count = 0
@retry(max_attempts=4, delay=0.1, exceptions=(ConnectionError,))
def fetch_data(url):
global call_count
call_count += 1
if call_count < 4:
raise ConnectionError(f"Cannot reach {url}")
return {"status": "ok"}
print(fetch_data("https://api.example.com"))
# Attempt 1/4 failed: Cannot reach https://api.example.com. Retrying in 0.1s...
# Attempt 2/4 failed: Cannot reach https://api.example.com. Retrying in 0.1s...
# Attempt 3/4 failed: Cannot reach https://api.example.com. Retrying in 0.1s...
# {'status': 'ok'}
The caller controls exactly how many retries to attempt, how long to wait between them, and which exception types to catch. All three parameters are captured in the factory's closure and available inside the wrapper on every invocation.
wrapper(func), which would fail because the wrapper expects *args, **kwargs (the arguments of the decorated function), not the function itself. Each level must return the next level down.# The return chain must be:
# factory -> returns decorator
# decorator -> returns wrapper
# wrapper -> runs on each call
def factory(param):
def decorator(func): # factory returns THIS
def wrapper(*args, **kwargs): # decorator returns THIS
return func(*args, **kwargs)
return wrapper
return decorator # NOT wrapper
@factory(params) evaluates factory(params) first to get the decorator, then applies it to the function.from functools import wraps
def log_calls(level="INFO"): # Factory: accepts params
def decorator(func): # Decorator: accepts function
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator # Factory returns decorator
# Python evaluates:
# 1. log_calls(level="DEBUG") -> returns decorator
# 2. decorator(add) -> returns wrapper
# 3. add is rebound to wrapper
# The original function is never returned directly.
# It is captured inside the wrapper's closure.
def factory(param):
def decorator(func): # func is captured here
def wrapper(*args, **kwargs):
# Original func is called INSIDE wrapper
return func(*args, **kwargs)
return wrapper # wrapper replaces func
return decorator # factory returns decorator
Class-Based Decorators with Parameters
A class can serve as a decorator factory by using __init__ to accept the custom parameters and __call__ to accept the function. This approach reduces visual nesting and makes it straightforward to maintain state across invocations:
import time
from functools import wraps
class RateLimit:
"""Class-based decorator that limits how often a function can be called."""
def __init__(self, max_calls, period_seconds):
"""Accept decorator parameters."""
self.max_calls = max_calls
self.period = period_seconds
self.call_times = []
def __call__(self, func):
"""Accept the function and return the wrapper."""
@wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# Remove calls outside the current period
self.call_times = [
t for t in self.call_times if now - t < self.period
]
if len(self.call_times) >= self.max_calls:
wait = self.period - (now - self.call_times[0])
raise RuntimeError(
f"Rate limit: {self.max_calls} calls per "
f"{self.period}s exceeded. Wait {wait:.1f}s."
)
self.call_times.append(now)
return func(*args, **kwargs)
return wrapper
@RateLimit(max_calls=3, period_seconds=10)
def send_notification(message):
print(f"Sent: {message}")
send_notification("Hello") # Sent: Hello
send_notification("World") # Sent: World
send_notification("Again") # Sent: Again
try:
send_notification("Too many")
except RuntimeError as e:
print(e)
# Rate limit: 3 calls per 10s exceeded. Wait 9.9s.
When Python encounters @RateLimit(max_calls=3, period_seconds=10), it calls RateLimit(max_calls=3, period_seconds=10), which creates an instance and stores the parameters in self.max_calls and self.period. Python then calls the instance with the function: instance(send_notification), which triggers __call__, and the wrapper is returned.
The class-based approach has an advantage over nested functions when the decorator needs to track state like call_times. With nested functions, you would need a mutable object in the closure (like a list or dictionary) to achieve the same effect. With a class, state lives naturally in instance attributes.
Choose the class-based approach when your decorator maintains mutable state that changes across calls (counters, caches, timestamps). Choose the function-based approach when the decorator is stateless and simply applies fixed behavior based on its parameters.
__init__ receives the decorator's custom parameters, not the function being decorated. When you write @RateLimit(max_calls=3, period_seconds=10), Python calls RateLimit(max_calls=3, period_seconds=10), which triggers __init__ with the configuration arguments. The function is passed in the next step.class MyDecorator:
def __init__(self, param):
# Step 1: receives YOUR parameters
self.param = param
def __call__(self, func):
# Step 2: receives the FUNCTION
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# @MyDecorator(param="value") triggers:
# 1. __init__(self, param="value") -> stores params
# 2. __call__(self, func) -> receives function
__call__ receives the function being decorated. After __init__ stores the custom parameters, Python calls the instance with the decorated function as its argument. Since the instance is callable (it has __call__), this triggers __call__(self, func), which returns the wrapper.from functools import wraps
class LogCalls:
def __init__(self, level="INFO"):
self.level = level # Store parameters
def __call__(self, func): # Receive the function
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{self.level}] {func.__name__}")
return func(*args, **kwargs)
return wrapper
@LogCalls(level="DEBUG")
def greet(name):
return f"Hello, {name}"
print(greet("Kandi"))
# [DEBUG] greet
# Hello, Kandi
__wrap__ is not a real Python dunder method. Python uses __call__ to make an object callable. When the class instance is called with the function as its argument, __call__ is invoked. The two methods involved are __init__ (receives parameters) and __call__ (receives the function).# There is no __wrap__ dunder method in Python.
# The relevant methods are:
# __init__ -> receives custom parameters
# __call__ -> receives the function, returns wrapper
class MyDecorator:
def __init__(self, param):
self.param = param
def __call__(self, func): # This is the real method
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
The functools.partial Shortcut
The functools.partial approach eliminates the outermost nesting level by using a func=None first parameter and keyword-only arguments for the custom parameters. When the decorator is used with arguments, it returns partial(itself, parameters). When used without arguments, it receives the function directly:
from functools import partial, wraps
def log_calls(func=None, *, level="INFO"):
if func is None:
# Called with arguments: @log_calls(level="DEBUG")
return partial(log_calls, level=level)
# Called without arguments: @log_calls
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# Works WITHOUT parentheses (uses default level="INFO"):
@log_calls
def greet(name):
return f"Hello, {name}"
# Works WITH arguments:
@log_calls(level="DEBUG")
def farewell(name):
return f"Goodbye, {name}"
print(greet("Kandi"))
# [INFO] Calling greet
# Hello, Kandi
print(farewell("Kandi"))
# [DEBUG] Calling farewell
# Goodbye, Kandi
This approach produces two levels of nesting instead of three. The trade-off is that all custom parameters must be keyword-only (placed after the * separator) to prevent Python from accidentally passing the decorated function as a parameter value.
| Approach | Nesting Levels | Works Without () | Stateful |
|---|---|---|---|
| Three-level factory | 3 | No (requires parentheses) | Possible via closure |
| Class-based | 1 class + 1 method | No (requires parentheses) | Yes (instance attributes) |
| functools.partial | 2 | Yes | Possible via closure |
*) in the functools.partial decorator pattern?functools.partial pattern because of how func=None works as the first parameter. Without the * separator, a positional parameter would collide with the function argument.# Standard three-level factories accept positional args fine:
def repeat(num_times): # positional, no problem
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(num_times):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(3) # positional argument works here
def greet():
print("Hi!")
partial pattern, the first parameter is func=None. When used without parentheses (@log_calls), Python passes the decorated function as the first positional argument. If your custom parameters were also positional, Python could pass the function as one of your parameter values instead of as func. The * separator forces your parameters to be keyword-only, ensuring func always gets the function.from functools import partial, wraps
# The * forces level to be keyword-only
def log_calls(func=None, *, level="INFO"):
if func is None:
return partial(log_calls, level=level)
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] {func.__name__}")
return func(*args, **kwargs)
return wrapper
# @log_calls -> func=greet, level="INFO" (default)
# @log_calls(level="DEBUG") -> func=None, returns partial
# partial then called with func=farewell
* separator ensures that only func can receive positional arguments.# Without *, this would be ambiguous:
# def log_calls(func=None, level="INFO"):
# @log_calls -> func=greet (correct)
# @log_calls("DEBUG") -> func="DEBUG" (BUG!)
#
# With *, level can only be passed as keyword:
# def log_calls(func=None, *, level="INFO"):
# @log_calls("DEBUG") -> TypeError (good, catches mistake)
# @log_calls(level="DEBUG") -> func=None (correct)
Common Mistakes and How to Fix Them
Mistake 1: Forgetting Parentheses on a Parameterized Decorator
Writing @retry instead of @retry() when the decorator is a factory passes the decorated function as the factory's first positional argument. This produces a confusing error because the factory tries to use the function as a parameter value:
from functools import wraps
def repeat(num_times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
# WRONG: @repeat without parentheses
# Python calls: repeat(say_hello)
# num_times receives the say_hello function object
# Then Python tries to call the result as: decorator(???)
# This fails because decorator expects a function, not nothing
# @repeat # <-- TypeError or unexpected behavior
# def say_hello():
# print("Hi!")
# CORRECT: always include parentheses
@repeat(3)
def say_hello():
print("Hi!")
say_hello()
# Hi!
# Hi!
# Hi!
If your factory has parameters with default values, you must still include empty parentheses: @repeat() not @repeat. Without parentheses, Python skips the factory call entirely and passes the function directly. The only exception is the functools.partial pattern, which is specifically designed to handle both forms.
Mistake 2: Forgetting functools.wraps
Without @wraps(func) on the wrapper function, the decorated function loses its original __name__, __doc__, and __module__. This is not specific to parameterized decorators, but it happens more frequently with them because there are more layers of nesting where @wraps can be forgotten:
from functools import wraps
# WITHOUT @wraps: metadata is lost
def tag_without_wraps(tag_name):
def decorator(func):
def wrapper(*args, **kwargs):
return f"<{tag_name}>{func(*args, **kwargs)}{tag_name}>"
return wrapper
return decorator
@tag_without_wraps("strong")
def greet_broken():
"""Return a greeting."""
return "Hello"
print(greet_broken.__name__) # wrapper (wrong)
print(greet_broken.__doc__) # None (wrong)
# WITH @wraps: metadata is preserved
def tag_with_wraps(tag_name):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<{tag_name}>{func(*args, **kwargs)}{tag_name}>"
return wrapper
return decorator
@tag_with_wraps("strong")
def greet_fixed():
"""Return a greeting."""
return "Hello"
print(greet_fixed.__name__) # greet_fixed (correct)
print(greet_fixed.__doc__) # Return a greeting. (correct)
Mistake 3: Returning the Wrong Thing from the Wrong Level
Each level must return the next level down. The factory returns the decorator. The decorator returns the wrapper. If you return the wrapper from the factory or the decorator from the factory, the chain breaks silently and the decorated function behaves unexpectedly:
from functools import wraps
# CORRECT return chain:
def repeat_correct(num_times):
def decorator(func): # factory returns THIS
@wraps(func)
def wrapper(*args, **kwargs): # decorator returns THIS
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper # <-- decorator returns wrapper
return decorator # <-- factory returns decorator
# WRONG: factory returns wrapper directly (skips decorator level)
# def repeat_wrong(num_times):
# def decorator(func):
# @wraps(func)
# def wrapper(*args, **kwargs):
# for _ in range(num_times):
# result = func(*args, **kwargs)
# return result
# return wrapper
# return wrapper # <-- BUG: returns wrapper instead of decorator
# # Python calls wrapper(say_hello) which fails
# # because wrapper expects *args/**kwargs, not func
The rule is consistent: each level captures one thing (parameters, the function, or the call arguments) and passes control to the next level by returning it. Breaking this chain by returning from the wrong level is the single most common structural error in parameterized decorators.
@my_factory without parentheses skips the factory call entirely. Python passes the decorated function directly to my_factory as its first positional argument. Instead of receiving a parameter, the factory receives a function object. This causes a TypeError or silent misbehavior depending on the factory's signature.from functools import wraps
def repeat(num_times=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
# WRONG: @repeat passes greet as num_times
# @repeat
# def greet():
# print("Hi!")
# greet() -> TypeError: 'function' cannot be interpreted as int
# CORRECT: @repeat() calls the factory with defaults
@repeat()
def greet():
print("Hi!")
greet()
# Hi!
# Hi!
# Hi!
@my_factory() calls the factory with default parameter values, which returns the decorator. Python then calls the decorator with the function. Without the parentheses, the factory itself is used as a plain decorator, which breaks the chain.from functools import wraps
def repeat(num_times=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
# @repeat() means:
# Step 1: repeat() -> returns decorator (num_times=3)
# Step 2: decorator(greet) -> returns wrapper
# Step 3: greet is rebound to wrapper
@repeat()
def greet():
print("Hi!")
greet()
# Hi!
# Hi!
# Hi!
@my_factory() calls the factory, which returns a decorator. @my_factory uses the factory itself as a decorator, passing the function as the first parameter. The only pattern where both forms work is the functools.partial shortcut, which explicitly handles the func=None case. Standard factories require parentheses.# Only the functools.partial pattern supports both:
from functools import partial, wraps
def log_calls(func=None, *, level="INFO"):
if func is None:
return partial(log_calls, level=level)
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls # Works (func=greet)
def greet():
return "Hi!"
@log_calls(level="X") # Works (func=None, returns partial)
def farewell():
return "Bye!"
# Standard factories do NOT support @factory without ()
Key Takeaways
- Standard decorators cannot accept custom parameters. Python passes only the decorated function to the decorator. There is no mechanism to pass additional arguments without adding an outer layer.
- The three-level pattern (decorator factory) is the standard approach. The outermost function accepts parameters, the middle function accepts the function, and the inner function executes on each call. Each level must return the next level down.
- Class-based decorators reduce visual nesting.
__init__captures parameters,__call__captures the function and returns the wrapper. This is the preferred approach when the decorator needs to maintain mutable state across calls. - The
functools.partialshortcut enables optional parentheses. By usingfunc=Noneand keyword-only parameters, the decorator works as both@decoratorand@decorator(arg=value), collapsing three levels to two. - Always include parentheses on parameterized decorators. Writing
@factoryinstead of@factory()passes the decorated function to the factory as its first parameter, which produces confusing errors. The only exception is thepartialpattern designed specifically for this case.
Passing parameters into a decorator requires one additional layer of function nesting beyond what a standard decorator uses. The three-level pattern is the canonical approach, class-based decorators offer better state management, and functools.partial eliminates the outermost layer when all parameters are optional and keyword-only. Regardless of which approach you use, the underlying mechanism is the same: the @ expression evaluates to a callable that accepts the function, and your parameters are captured in a closure or instance before that callable is produced.
How to Pass Custom Parameters Into a Python Decorator
- Define the factory function. Create the outermost function that accepts your custom parameters. This is the function name that will appear after the
@symbol. Give each parameter a clear name and a sensible default value if applicable. - Define the decorator function inside the factory. Inside the factory, define a function that accepts a single argument: the function being decorated. This is the standard decorator level that Python's
@syntax calls with the target function. - Define the wrapper function inside the decorator. Inside the decorator, define the wrapper function with
*argsand**kwargs. Apply@functools.wraps(func)to preserve the original function's metadata. Implement your custom logic here, using the parameters captured from the factory's closure. - Return the correct function from each level. The wrapper returns the result of calling the original function. The decorator returns the wrapper. The factory returns the decorator. Each level must return the next level down -- returning from the wrong level is the single most common structural error.
- Apply the decorator with parentheses. Use
@factory(param=value)syntax. The parentheses call the factory with your parameters, and Python then applies the returned decorator to the function. Always include parentheses, even when relying on default values:@factory()not@factory.
Frequently Asked Questions
Why can't I just add parameters to a regular decorator function?
A standard decorator function always receives exactly one argument from Python: the function being decorated. There is no place in the call signature for additional arguments. To accept custom parameters, you need a decorator factory, which is an outer function that takes the parameters and returns a standard decorator.
What is a decorator factory in Python?
A decorator factory is a function that accepts configuration arguments and returns a decorator. The returned decorator is then applied to the target function. This creates a three-level structure: the factory (outermost), the decorator (middle), and the wrapper (innermost). The factory captures the parameters in its closure, making them accessible to the decorator and wrapper.
Why do I need three levels of nested functions for a parameterized decorator?
The three levels serve distinct roles. The outermost function (factory) accepts the decorator's parameters. The middle function (decorator) accepts the function to be decorated. The innermost function (wrapper) runs each time the decorated function is called. Without the outermost level, there is no mechanism to capture custom parameters before the function is passed in.
Can I use a class instead of three nested functions?
Yes. A class-based decorator uses __init__ to accept the parameters and __call__ to accept the function being decorated. The __call__ method returns a wrapper function. This approach reduces visual nesting and makes it easier to maintain state across invocations.
What happens if I write @decorator instead of @decorator() when the decorator expects arguments?
Python passes the decorated function as the first argument to the factory function instead of the expected parameter. This typically causes a TypeError because the factory tries to use the function object as a parameter value. Always include parentheses when using a parameterized decorator, even if all arguments are optional: @decorator() not @decorator.