Parameterized decorators in Python require three levels of nested functions: a factory that accepts arguments, a decorator that accepts the function, and a wrapper that executes the logic. The nesting is correct but difficult to read and easy to get wrong. functools.partial offers a way to collapse that structure by pre-filling arguments into a two-level decorator, producing the same behavior with less indentation and fewer mental layers to track. This article explains the technique from the ground up, with code examples at every stage.
Before applying partial to decorators, it helps to understand what partial does on its own. The rest of the article builds on that foundation, first demonstrating the standard triple-nested decorator pattern, then showing how partial replaces it with a flatter structure, and finally covering the edge cases where the technique does and does not apply.
What functools.partial Does
functools.partial creates a new callable from an existing function by freezing some of its arguments. The resulting partial object behaves like the original function, but the frozen arguments are automatically supplied on every call. Any remaining arguments are passed through normally.
from functools import partial
def power(base, exponent):
return base ** exponent
# Freeze exponent=2 to create a squaring function
square = partial(power, exponent=2)
# Freeze exponent=3 to create a cubing function
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
# The partial object remembers what was frozen
print(square.func) # <function power at 0x...>
print(square.keywords) # {'exponent': 2}
square and cube are both partial objects. Each one wraps the power function with one argument already locked in. When square(5) is called, partial calls power(5, exponent=2) behind the scenes. The remaining argument (base) is supplied by the caller.
The key insight is that partial creates a new callable from an existing callable plus some pre-filled arguments. This is the exact operation that a decorator factory performs: it takes configuration arguments and returns a decorator (a callable that expects a function). If the factory is just pre-filling arguments into a general-purpose decorator function, partial can do that job directly.
Given square = partial(power, exponent=2), what does square.keywords return?
Not quite. (2,) would be the value of square.args if 2 had been passed as a positional argument. Since exponent=2 was passed as a keyword argument, it is stored in .keywords, not .args. A partial object tracks positional and keyword arguments separately:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
print(square.args) # () -- no positional args frozen
print(square.keywords) # {'exponent': 2} -- keyword args frozen
Correct. The .keywords attribute on a partial object is a dictionary containing every keyword argument that was frozen at creation time. Since exponent=2 was passed as a keyword argument, it appears here:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
print(square.func) # <function power at 0x...>
print(square.args) # ()
print(square.keywords) # {'exponent': 2}
Not quite. {'base': 2} would only appear if you had written partial(power, base=2). The original call froze exponent=2, so .keywords reflects exactly what was passed:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, base=5)
print(square.keywords) # {'exponent': 2}
print(cube.keywords) # {'base': 5}
The Triple-Nesting Problem
A simple decorator that takes no arguments requires two levels of nesting: the decorator function and the wrapper function inside it. When the decorator needs to accept configuration arguments, a third level is required. The outermost function accepts the arguments, returns the decorator, which in turn returns the wrapper. This three-level structure is the standard parameterized decorator factory pattern:
import functools
def repeat(num_times): # Level 1: factory
def decorator_repeat(func): # Level 2: decorator
@functools.wraps(func)
def wrapper_repeat(*args, **kwargs): # Level 3: wrapper
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper_repeat
return decorator_repeat
@repeat(num_times=3)
def say_hello(name):
print(f"Hello, {name}")
say_hello("Kandi")
# Hello, Kandi
# Hello, Kandi
# Hello, Kandi
This works, but the three levels of indentation obscure what is fundamentally a simple idea: run the function num_times times. The outermost function (repeat) exists only to capture num_times and pass it into the closure. It adds visual noise without adding logic.
The triple-nesting pattern also has a usability limitation: it forces the caller to always use parentheses. Writing @repeat without parentheses passes the decorated function to repeat as the num_times argument, which fails because a function is not an integer. The decorator requires @repeat() at minimum, even when using default values.
The three-level pattern is not wrong. It is the standard way to write parameterized decorators and will always work correctly. The partial approach is an alternative that reduces nesting for cases where the factory function does nothing beyond capturing arguments.
The partial Pattern for Decorators
The technique, described in David Beazley and Brian K. Jones's Python Cookbook, uses a single function with func=None as its first parameter and the configuration arguments as keyword-only parameters after it. When the decorator is used without arguments, Python passes the function directly as func. When it is used with arguments, func is None, and the function returns partial(itself, config_args), which Python then calls with the function.
from functools import partial, wraps
def repeat(func=None, *, num_times=2):
if func is None:
# Called with arguments: @repeat(num_times=3)
# Return a partial that will be called with func next
return partial(repeat, num_times=num_times)
# Called without arguments: @repeat
# Or called by the partial with func filled in
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
# Usage 1: without arguments (uses default num_times=2)
@repeat
def greet(name):
print(f"Hi, {name}")
# Usage 2: with arguments
@repeat(num_times=4)
def farewell(name):
print(f"Bye, {name}")
greet("Kandi")
# Hi, Kandi
# Hi, Kandi
farewell("Kandi")
# Bye, Kandi
# Bye, Kandi
# Bye, Kandi
# Bye, Kandi
This is two levels of nesting instead of three. The repeat function itself is the decorator. When called with arguments like @repeat(num_times=4), the if func is None branch fires and returns partial(repeat, num_times=4). That partial object is a callable that expects one argument: the function to decorate. Python calls it with the decorated function, which re-enters repeat with func set to the function and num_times set to 4. The flow reaches the @wraps(func) section and returns the wrapper.
When called without arguments like @repeat, Python passes the decorated function directly as func. The if func is None check fails, and the function proceeds directly to create and return the wrapper with the default num_times=2.
Step-by-Step Trace
Understanding the two call paths is essential. Here they are traced explicitly:
# Path A: @repeat (no parentheses)
# Python sees @repeat and calls repeat(greet)
# func=greet, num_times=2 (default)
# func is not None -> create wrapper -> return wrapper
# greet is now the wrapper
# Path B: @repeat(num_times=4)
# Python sees @repeat(num_times=4) and calls repeat(num_times=4)
# func=None (default), num_times=4
# func is None -> return partial(repeat, num_times=4)
# Python then calls partial(repeat, num_times=4)(farewell)
# Which is equivalent to: repeat(farewell, num_times=4)
# func=farewell, num_times=4
# func is not None -> create wrapper -> return wrapper
# farewell is now the wrapper
The * separator before num_times in the function signature is critical. It forces all configuration arguments to be keyword-only, which prevents Python from accidentally passing the decorated function as a positional argument to one of the configuration parameters.
Without the * separator, writing @repeat(3) would pass 3 as func, not as num_times. The keyword-only enforcement ensures that func can only be filled by position (by Python's decorator machinery) or by partial, never by the user's arguments.
A Logging Decorator Using the Pattern
Here is a more practical example: a logging decorator with a configurable log level that works both with and without arguments:
import logging
from functools import partial, wraps
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def log_call(func=None, *, level=logging.INFO):
if func is None:
return partial(log_call, level=level)
@wraps(func)
def wrapper(*args, **kwargs):
logger.log(level, "Calling %s", func.__name__)
result = func(*args, **kwargs)
logger.log(level, "%s returned %r", func.__name__, result)
return result
return wrapper
# Without arguments: logs at default INFO level
@log_call
def fetch_user(user_id):
return {"id": user_id, "name": "Kandi"}
# With arguments: logs at DEBUG level
@log_call(level=logging.DEBUG)
def fetch_config(key):
return {"key": key, "value": "enabled"}
fetch_user(42)
# INFO:__main__:Calling fetch_user
# INFO:__main__:fetch_user returned {'id': 42, 'name': 'Kandi'}
fetch_config("feature_flag")
# DEBUG:__main__:Calling fetch_config
# DEBUG:__main__:fetch_config returned {'key': 'feature_flag', 'value': 'enabled'}
The decorator seamlessly handles both @log_call and @log_call(level=logging.DEBUG). The partial mechanism makes this possible without a third layer of nesting.
In the func=None pattern, what happens when you write @repeat(num_times=4)?
Not in this pattern. Passing the function as num_times is the failure mode of the old triple-nested pattern when you forget parentheses. The func=None pattern with keyword-only arguments after * prevents this because num_times can only be supplied as a keyword:
from functools import partial, wraps
def repeat(func=None, *, num_times=2):
if func is None:
# @repeat(num_times=4) lands here
# func is None, so return a partial
return partial(repeat, num_times=num_times)
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
Correct. When @repeat(num_times=4) is evaluated, Python calls repeat(num_times=4). Since func defaults to None, the if func is None branch fires and returns partial(repeat, num_times=4). Python then calls that partial with the decorated function, which re-enters repeat with both func and num_times set:
# Step 1: repeat(num_times=4)
# func=None -> return partial(repeat, num_times=4)
#
# Step 2: partial(repeat, num_times=4)(farewell)
# equivalent to repeat(farewell, num_times=4)
# func=farewell, num_times=4
# func is not None -> build wrapper -> return wrapper
Not on the first call. When @repeat(num_times=4) is evaluated, func is None (its default), so the function cannot create a wrapper yet because it does not have a function to wrap. It must first return a partial that Python then calls with the actual function:
from functools import partial, wraps
def repeat(func=None, *, num_times=2):
if func is None:
# First call: no function yet, return partial
return partial(repeat, num_times=num_times)
# Second call (via partial): func is now set
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper # wrapper created HERE on second call
Creating Pre-Configured Decorator Variants
Once you have a decorator that accepts keyword arguments, partial can also create named variants with different default configurations. This is useful when you have a general-purpose decorator and want to offer specialized versions for common use cases:
import time
from functools import partial, wraps
def retry(func=None, *, max_attempts=3, delay=1.0, exceptions=(Exception,)):
if func is None:
return partial(
retry,
max_attempts=max_attempts,
delay=delay,
exceptions=exceptions,
)
@wraps(func)
def wrapper(*args, **kwargs):
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} failed: {e}. "
f"Retrying in {delay}s...")
time.sleep(delay)
raise last_error
return wrapper
# Create named variants with partial
retry_network = partial(
retry,
max_attempts=5,
delay=2.0,
exceptions=(ConnectionError, TimeoutError),
)
retry_fast = partial(
retry,
max_attempts=2,
delay=0.1,
)
# Use the generic version
@retry
def flaky_operation():
import random
if random.random() < 0.7:
raise ValueError("Random failure")
return "success"
# Use the network-specific variant
@retry_network
def call_api(url):
raise ConnectionError(f"Cannot reach {url}")
# Use the fast variant
@retry_fast
def quick_check():
raise RuntimeError("Still broken")
# Test the generic version
try:
print(flaky_operation())
except ValueError:
print("flaky_operation failed after 3 attempts")
retry_network and retry_fast are partial objects that pre-fill different configurations into the retry decorator. They are used with the @ syntax exactly like the base retry decorator. This approach avoids writing separate decorator functions for each configuration and keeps the logic centralized in one place.
Named decorator variants created with partial are a good fit for team codebases where different modules need the same cross-cutting behavior but with different configurations. A shared decorators.py module can export both the base decorator and its named variants.
How functools.wraps Itself Uses partial
The functools.wraps function that Python provides for preserving decorator metadata is itself implemented using partial. According to the Python documentation, @wraps(wrapped) is equivalent to partial(update_wrapper, wrapped=wrapped). This means that every time you write @wraps(func) inside a decorator, you are using partial to pre-fill the wrapped argument of update_wrapper:
from functools import update_wrapper, partial
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
# These two lines are equivalent:
# @wraps(func) on the wrapper
# OR:
update_wrapper(wrapper, func)
# Which is what wraps(func) does internally via:
# partial(update_wrapper, wrapped=func)(wrapper)
return wrapper
@my_decorator
def example():
"""Example docstring."""
return 42
print(example.__name__) # example
print(example.__doc__) # Example docstring.
This is not just a theoretical connection. Understanding that wraps is built on partial makes the decorator ecosystem feel more consistent: the same tool (partial) that simplifies writing decorators is also used internally to implement the standard metadata-preservation mechanism.
Limitations and When Not to Use partial
The partial pattern is not universally applicable. There are cases where the triple-nested approach is the better choice.
When the Factory Needs Logic
If the outermost function in a triple-nested decorator does more than capture arguments, such as computing derived values, performing validation on the configuration, or registering the decorator in a lookup table, partial cannot replace it. partial only pre-fills arguments; it does not run arbitrary code before creating the decorator:
import functools
# This factory does LOGIC beyond capturing arguments.
# partial cannot replace it.
_registry = {}
def register_handler(event_type):
"""Register the decorated function as a handler for event_type."""
if event_type not in ("click", "submit", "hover"):
raise ValueError(f"Unknown event type: {event_type}")
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
_registry[event_type] = wrapper
return wrapper
return decorator
@register_handler("click")
def handle_click(event):
print(f"Clicked: {event}")
print(_registry)
# {'click': <function handle_click at 0x...>}
The factory validates event_type and registers the wrapper in _registry. These are side effects that must happen at decoration time. partial cannot replicate this behavior because it only freezes arguments; it does not execute code during the freezing step.
When Positional Arguments Conflict
The func=None pattern relies on func being the first positional parameter and all configuration arguments being keyword-only (after *). If the decorator's configuration naturally involves a required positional argument, the pattern becomes ambiguous because Python cannot distinguish between the user's positional argument and the decorated function:
# This does NOT work with the partial pattern:
# @tag("bold") needs a required positional argument
# but @tag alone would try to pass func as the tag name
import functools
def tag(tag_name):
"""Triple-nested is the correct approach here."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"<{tag_name}>{result}</{tag_name}>"
return wrapper
return decorator
@tag("strong")
def greet():
return "Hello"
print(greet()) # <strong>Hello</strong>
Because tag_name is a required positional argument, there is no way for the decorator to tell whether it was called as @tag(some_function) or @tag("bold"). Both pass a single positional argument. The partial pattern works only when the function is passed in a position that cannot conflict with user-supplied arguments, which requires all user arguments to be keyword-only.
All config args are keyword-only with defaults Yes
Ideal case: func=None + * separator works cleanly.
Decorator must work with and without parentheses Yes
The primary use case for the pattern.
Factory performs validation or side effects No
partial cannot run logic during argument freezing.
Config includes required positional arguments No
Ambiguity between func and the positional arg.
Creating named pre-configured variants Yes
partial(decorator, config=value) creates reusable variants.
You want a decorator that validates its event_type argument against a whitelist at decoration time. Can you use the partial pattern for this?
Putting validation inside the wrapper would run it on every function call, not at decoration time. The requirement is to validate event_type once, when the decorator is applied, and raise an error before the function is ever called. That requires code execution in the factory layer, which partial cannot provide:
# Validation MUST happen here, at decoration time:
def register_handler(event_type):
if event_type not in ("click", "submit", "hover"):
raise ValueError(f"Unknown event type: {event_type}")
def decorator(func):
# ... wrapper logic ...
return wrapper
return decorator
# This raises ValueError immediately when the module loads,
# not when handle_upload() is eventually called:
@register_handler("upload") # ValueError: Unknown event type: upload
def handle_upload(event):
pass
Not quite. partial only stores the arguments for later use -- it does not execute any code when the arguments are frozen. It has no mechanism for running validation, performing side effects, or raising errors at freeze time:
from functools import partial
def power(base, exponent):
return base ** exponent
# partial does NOT validate that exponent is an int.
# It blindly stores whatever you pass:
bad = partial(power, exponent="banana")
# The error only surfaces when you call it:
bad(5) # TypeError: unsupported operand type(s) for **: 'int' and 'str'
Correct. partial is purely mechanical -- it stores the arguments and forwards them later. It has no hook for running custom code during the freezing step. When your decorator factory needs to validate arguments, register side effects, or compute derived values at decoration time, you need the explicit triple-nested structure where the outermost function can execute arbitrary logic:
import functools
_registry = {}
def register_handler(event_type):
# This logic runs at decoration time -- partial cannot do this
if event_type not in ("click", "submit", "hover"):
raise ValueError(f"Unknown event type: {event_type}")
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
_registry[event_type] = wrapper
return wrapper
return decorator
Inspecting partial Objects
A partial object exposes three attributes that are useful for debugging: .func (the original function), .args (frozen positional arguments), and .keywords (frozen keyword arguments). When debugging decorator behavior, these attributes let you inspect what was pre-filled:
from functools import partial, wraps
def throttle(func=None, *, calls_per_second=10):
if func is None:
return partial(throttle, calls_per_second=calls_per_second)
@wraps(func)
def wrapper(*args, **kwargs):
# Throttling logic would go here
return func(*args, **kwargs)
return wrapper
# When used with arguments, the intermediate result is a partial
configured = throttle(calls_per_second=5)
print(type(configured)) # <class 'functools.partial'>
print(configured.func) # <function throttle at 0x...>
print(configured.keywords) # {'calls_per_second': 5}
print(configured.args) # ()
# After applying to a function, the result is the wrapper
@throttle(calls_per_second=5)
def api_call():
return "response"
print(type(api_call)) # <class 'function'>
print(api_call.__name__) # api_call
The intermediate partial object is visible if you call the decorator with arguments but do not immediately apply it to a function. Once applied, the final result is the wrapper function with the original function's metadata preserved by @wraps.
Key Takeaways
functools.partialfreezes arguments into a callable. It creates a new callable that behaves like the original function but with some arguments pre-filled. Remaining arguments are supplied by the caller.- The
func=Nonepattern replaces triple nesting with two levels. By checking whetherfuncisNone, the decorator handles both@decoratorand@decorator(arg=value)syntax. WhenfuncisNone, the function returnspartial(itself, config_args), which Python calls with the decorated function. - Configuration arguments must be keyword-only. The
*separator in the function signature ensures that user-supplied arguments cannot be confused with thefuncparameter. Without it, positional arguments create ambiguity. - Named variants are created by calling
partialdirectly.partial(decorator, config=value)produces a reusable, pre-configured decorator that can be applied with the@syntax like any other decorator. - The pattern does not replace factories that perform logic. If the outermost function in a triple-nested decorator validates arguments, registers functions, or performs side effects at decoration time,
partialcannot substitute for it. Use the standard triple-nested structure in those cases.
functools.partial is a general-purpose tool for pre-filling function arguments, and its application to decorator syntax is one of the cleanest uses of the technique. By collapsing the outermost factory layer into a partial return, you reduce visual nesting, gain the ability to use the decorator with and without parentheses, and open the door to creating named decorator variants without writing additional functions. The trade-off is a small increase in conceptual overhead for readers unfamiliar with partial, but for teams that work with decorators regularly, the reduced nesting pays for itself in readability and maintainability.
- Import partial and wraps from functools.
partialhandles argument pre-filling andwrapspreserves the decorated function's metadata. - Define the decorator with
func=Noneand keyword-only config arguments. Place all configuration parameters after the*separator so they can only be supplied as keyword arguments, preventing ambiguity with thefuncparameter. - Add the
if func is Nonebranch that returns a partial. When the decorator is called with arguments,funcisNone, so returnpartial(decorator_name, config_args). Python calls this partial with the decorated function. - Write the wrapper function with
@wraps(func). Below theNonecheck, define the inner wrapper containing the decorator logic and return it as the final decorated function. - Apply the decorator with or without arguments. Use
@decoratorfor default configuration or@decorator(arg=value)for custom configuration. Both forms work because thefunc=Noneandpartialmechanism handles both call paths.
- What does functools.partial do?
functools.partialcreates a new callable by freezing some positional or keyword arguments of an existing function. The resulting partial object behaves like the original function but with fewer required arguments, since the frozen arguments are automatically supplied on every call.- How does functools.partial simplify decorator syntax?
- Parameterized decorators normally require three levels of nested functions: the outer factory, the decorator, and the wrapper. Using
functools.partial, you can write the decorator as a two-level function that checks whether the decorated function was passed directly. If not, it returns a partial of itself with the configuration arguments pre-filled, eliminating the outermost nesting level. - Can a decorator built with functools.partial be used both with and without arguments?
- Yes. The pattern uses a
func=Nonedefault parameter. When the decorator is applied without arguments (@decorator),funcreceives the function directly. When applied with arguments (@decorator(arg=value)),funcisNoneand the decorator returnspartial(decorator, arg=value), which Python then calls with the function. - Does functools.partial preserve function metadata?
- No. A partial object does not automatically carry the original function's
__name__or__doc__. When using partial inside a decorator pattern, you still need@functools.wraps(func)on the inner wrapper function to preserve the decorated function's metadata. - When should you use functools.partial instead of triple-nested decorator functions?
- Use the partial pattern when you want a decorator that works both with and without parentheses, when you want to reduce nesting depth for readability, or when you are creating multiple pre-configured decorator variants from a single base decorator function.