Python's @dataclass works as both @dataclass and @dataclass(frozen=True). Flask's @app.route requires parentheses. Your custom @retry forces users to remember which form to use. This inconsistency creates friction. This article covers every technique for building decorators that accept optional arguments and work seamlessly with or without parentheses, so callers never have to think about it.
The root of the problem is that Python's @ syntax has two completely different code paths depending on whether you include parentheses. Understanding both paths is the foundation for building decorators that handle either one.
The Problem: Two Calling Conventions
When Python encounters @decorator without parentheses, it passes the decorated function directly to decorator as the first argument. The return value of decorator(func) replaces the original function. When Python encounters @decorator() or @decorator(arg=value) with parentheses, it first calls decorator() or decorator(arg=value), which must return a callable. That returned callable then receives the decorated function as its argument.
Here is what each form translates to when the syntactic sugar is removed:
# Without parentheses: @decorator applied to greet
@decorator
def greet():
pass
# Equivalent to: greet = decorator(greet)
# With empty parentheses: @decorator() called first
@decorator()
def greet():
pass
# Equivalent to: greet = decorator()(greet)
# With arguments: @decorator(retries=3) called first
@decorator(retries=3)
def greet():
pass
# Equivalent to: greet = decorator(retries=3)(greet)
The difference is one call versus two. Without parentheses, one call does the full job. With parentheses, the first call configures the decorator and returns a second callable that performs the wrapping. A decorator that supports both forms must detect which path is happening and respond accordingly.
To see the problem concretely, here is a decorator that only works with arguments. Calling it without parentheses produces confusing behavior:
from functools import wraps
def retry(times=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
raise last_exception
return wrapper
return decorator
# Works fine with parentheses:
@retry(times=5)
def fetch_data():
pass
# Works fine with empty parentheses:
@retry()
def fetch_data():
pass
# Breaks without parentheses:
@retry
def fetch_data():
pass
# fetch_data is now the inner 'decorator' function, not a wrapper.
# Calling fetch_data() passes no arguments to 'decorator',
# which expects a callable, producing a TypeError.
The failure is silent and delayed. fetch_data becomes the inner decorator function, not a wrapped version of the original. The error only surfaces when someone tries to call fetch_data() and the argument mismatch triggers a TypeError. This is exactly the kind of bug that wastes hours in debugging.
The Callable Detection Pattern
The standard solution is to make the decorator's first positional parameter accept either the function being decorated (no parentheses) or None (parentheses used). The decorator inspects this parameter to decide which code path to take.
Basic Structure
The key insight is using keyword-only arguments (everything after *) for configuration, and reserving the first positional parameter for the optional function:
from functools import wraps
import time
def retry(func=None, *, times=3, delay=0):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_exception = e
if delay and attempt < times - 1:
time.sleep(delay)
raise last_exception
return wrapper
if func is not None:
# Called without parentheses: @retry
return decorator(func)
# Called with parentheses: @retry() or @retry(times=5)
return decorator
Now all three calling conventions work identically:
# All three produce the same result:
@retry
def fetch_data():
"""Uses defaults: times=3, delay=0"""
pass
@retry()
def fetch_data():
"""Uses defaults: times=3, delay=0"""
pass
@retry(times=5, delay=1)
def fetch_data():
"""Custom: times=5, delay=1"""
pass
The * separator is critical. It forces times and delay to be keyword-only arguments, which prevents a caller from accidentally passing a non-callable as the first positional argument. Without the *, someone writing @retry(5) would pass 5 as func, and the callable check would fail silently.
This is exactly the pattern Python's built-in @dataclass uses. Its signature is dataclass(cls=None, *, init=True, repr=True, eq=True, ...). When you write @dataclass, cls receives the class. When you write @dataclass(frozen=True), cls is None and the function returns a decorator.
Adding Robustness With an Explicit Callable Check
For extra safety, you can validate that func is indeed callable rather than relying solely on whether it is None. This catches misuse like @retry("not_a_function"):
def retry(func=None, *, times=3, delay=0):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_exception = e
raise last_exception
return wrapper
if func is not None:
if not callable(func):
raise TypeError(
"First argument must be callable. "
"Did you pass a positional argument? "
"Use keyword arguments: @retry(times=5)"
)
return decorator(func)
return decorator
The functools.partial Pattern
An alternative approach replaces the manual if/else branching with functools.partial. When the decorator is called with configuration arguments but no function, it returns a partial application of itself with those arguments pre-filled:
from functools import partial, wraps
def log_calls(func=None, *, level="INFO", include_args=True):
if func is None:
return partial(log_calls, level=level, include_args=include_args)
@wraps(func)
def wrapper(*args, **kwargs):
if include_args:
arg_str = ", ".join(
[repr(a) for a in args] +
[f"{k}={v!r}" for k, v in kwargs.items()]
)
print(f"[{level}] {func.__name__}({arg_str})")
else:
print(f"[{level}] {func.__name__}()")
return func(*args, **kwargs)
return wrapper
# All three forms work:
@log_calls
def process(data):
return len(data)
@log_calls()
def process(data):
return len(data)
@log_calls(level="DEBUG", include_args=False)
def process(data):
return len(data)
process([1, 2, 3]) # [DEBUG] process()
The partial approach is functionally equivalent to the if/else pattern, but it has a structural advantage: when func is None, the return statement is a single expression (partial(log_calls, ...)) rather than a nested function definition. For decorators with complex configuration logic, this keeps the code flatter.
The partial object returned by partial(log_calls, level=level, include_args=include_args) is a callable that, when called with a single argument (the decorated function), invokes log_calls(func, level=level, include_args=include_args). This time func is not None, so the decorator wraps it and returns the wrapper.
Combining partial With Type Annotations
When adding type hints, the partial pattern integrates cleanly with ParamSpec and TypeVar for fully typed decorators:
from functools import partial, wraps
from collections.abc import Callable
from typing import TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def validate_output(
func: Callable[P, R] | None = None,
*,
expected_type: type = object,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
if func is None:
return partial(validate_output, expected_type=expected_type)
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
result = func(*args, **kwargs)
if not isinstance(result, expected_type):
raise TypeError(
f"{func.__name__} returned {type(result).__name__}, "
f"expected {expected_type.__name__}"
)
return result
return wrapper
@validate_output
def get_name() -> str:
return "Alice"
@validate_output(expected_type=int)
def get_count() -> int:
return 42
Class-Based Optional-Parentheses Decorators
When a decorator carries significant state or needs multiple helper methods, a class-based approach can be cleaner than deeply nested functions. The challenge is making the class work both as @MyDecorator and @MyDecorator(option=value).
The trick is in __init__ and __call__. When used without parentheses, __init__ receives the function and __call__ acts as the wrapper. When used with parentheses, __init__ receives configuration and __call__ receives the function and returns the wrapper:
import functools
import time
class Timer:
"""Decorator that measures and prints execution time.
Usage:
@Timer
def fast_func(): ...
@Timer(label="DB Query")
def slow_func(): ...
"""
def __init__(self, func=None, *, label=None, threshold=0.0):
self.label = label
self.threshold = threshold
self.func = None
if func is not None:
# Called without parentheses: @Timer
self._wrap(func)
def _wrap(self, func):
self.func = func
functools.update_wrapper(self, func)
def __call__(self, *args, **kwargs):
if self.func is None:
# Called with parentheses: @Timer(label="x")
# __call__ receives the function
func = args[0]
self._wrap(func)
return self
# Normal call: execute the wrapped function
start = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed = time.perf_counter() - start
display_label = self.label or self.func.__name__
if elapsed >= self.threshold:
print(f"[{display_label}] {elapsed:.6f}s")
return result
def __get__(self, obj, objtype=None):
# Support instance methods
if obj is None:
return self
return functools.partial(self.__call__, obj)
@Timer
def compute_sum(n):
return sum(range(n))
@Timer(label="Heavy Computation", threshold=0.01)
def compute_product(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
compute_sum(1_000_000)
compute_product(10_000)
The __get__ method is necessary if you want the decorator to work on instance methods. Without it, self from the class would shadow the instance's self argument. Implementing the descriptor protocol via __get__ ensures proper method binding.
Class-based decorators replace the original function with a class instance. This means isinstance(compute_sum, Timer) is True, not isinstance(compute_sum, types.FunctionType). If downstream code checks for function types, class-based decorators can cause issues. The function-based patterns in earlier sections do not have this problem because they return a plain function.
Real-World Examples
Rate Limiter With Optional Configuration
This decorator limits how frequently a function can be called, with a configurable interval that defaults to one second:
from functools import partial, wraps
import time
def rate_limit(func=None, *, calls_per_second=1.0):
if func is None:
return partial(rate_limit, calls_per_second=calls_per_second)
min_interval = 1.0 / calls_per_second
last_call_time = 0.0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal last_call_time
now = time.monotonic()
elapsed = now - last_call_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
last_call_time = time.monotonic()
return func(*args, **kwargs)
return wrapper
@rate_limit
def default_api_call():
"""Limited to 1 call per second."""
print("called at", time.strftime("%H:%M:%S"))
@rate_limit(calls_per_second=5)
def fast_api_call():
"""Limited to 5 calls per second."""
print("called at", time.strftime("%H:%M:%S"))
for _ in range(3):
default_api_call()
Caching With Optional TTL
A cache decorator that can be used bare for unlimited caching or configured with a time-to-live:
from functools import partial, wraps
import time
def cache(func=None, *, ttl=None, maxsize=128):
if func is None:
return partial(cache, ttl=ttl, maxsize=maxsize)
_cache = {}
_timestamps = {}
@wraps(func)
def wrapper(*args):
now = time.monotonic()
# Check if cached value exists and is still valid
if args in _cache:
if ttl is None or (now - _timestamps[args]) < ttl:
return _cache[args]
else:
del _cache[args]
del _timestamps[args]
# Evict oldest entry if cache is full
if len(_cache) >= maxsize:
oldest_key = next(iter(_cache))
del _cache[oldest_key]
del _timestamps[oldest_key]
result = func(*args)
_cache[args] = result
_timestamps[args] = now
return result
wrapper.cache_clear = lambda: (_cache.clear(), _timestamps.clear())
wrapper.cache_info = lambda: {
"size": len(_cache), "maxsize": maxsize, "ttl": ttl
}
return wrapper
@cache
def fibonacci(n):
"""Cached forever, up to 128 entries."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
@cache(ttl=60, maxsize=256)
def fetch_user(user_id):
"""Cached for 60 seconds, up to 256 entries."""
print(f"Fetching user {user_id} from database...")
return {"id": user_id, "name": f"User {user_id}"}
print(fibonacci(30)) # 832040
print(fetch_user(42)) # Fetches from "database"
print(fetch_user(42)) # Returns cached result
print(fetch_user.cache_info()) # {'size': 1, 'maxsize': 256, 'ttl': 60}
Building a Generic Decorator Factory
If you find yourself writing the func=None / partial boilerplate repeatedly, you can extract it into a higher-order function that converts any decorator-with-arguments into one that supports optional parentheses:
from functools import partial, wraps
def flexible_decorator(decorator_func):
"""Meta-decorator that adds optional-parentheses support.
Converts a decorator factory (a function that returns a decorator)
into one that also works without parentheses.
"""
@wraps(decorator_func)
def wrapper(func=None, **kwargs):
if func is None:
return partial(wrapper, **kwargs)
return decorator_func(**kwargs)(func)
return wrapper
# Now you can write simple decorator factories
# and they automatically support all three calling forms:
@flexible_decorator
def log_execution(*, prefix="LOG"):
def decorator(func):
@wraps(func)
def inner(*args, **kwargs):
print(f"[{prefix}] Entering {func.__name__}")
result = func(*args, **kwargs)
print(f"[{prefix}] Exiting {func.__name__}")
return result
return inner
return decorator
@log_execution
def task_a():
print("Running task A")
@log_execution(prefix="DEBUG")
def task_b():
print("Running task B")
task_a()
# [LOG] Entering task_a
# Running task A
# [LOG] Exiting task_a
task_b()
# [DEBUG] Entering task_b
# Running task B
# [DEBUG] Exiting task_b
The flexible_decorator function is itself a decorator. It wraps any decorator factory (a function that returns a decorator) and adds the func=None detection logic automatically. Every decorator you build on top of it supports all three calling forms without any additional boilerplate.
Key Takeaways
| Pattern | Best For | Trade-Off |
|---|---|---|
| Callable detection (func=None, *) | Simple decorators with 1-3 options | Requires keyword-only enforcement via * |
| functools.partial | Flat code with many options | Slightly less explicit control flow |
| Class-based (__init__ + __call__) | Stateful decorators, method support | Replaces function with class instance; needs __get__ |
| Generic factory (flexible_decorator) | Teams writing many decorators | Extra abstraction layer to understand |
- Always use keyword-only arguments for decorator options. The
*separator in the function signature prevents callers from accidentally passing configuration values as the first positional argument, which would be misidentified as the decorated function. - Always use
functools.wraps. Every wrapper function should be decorated with@wraps(func)to preserve the original function's name, docstring, module, and signature. Without it, introspection tools and documentation generators see the wrapper's metadata instead. - The
func=None+partialpattern covers the vast majority of cases. It is the same approach used by Python's standard library (including@dataclass) and works for both function and class decorators. Start here before reaching for more complex solutions. - Use class-based decorators only when the decorator needs persistent state. If your decorator tracks call counts, maintains a cache with eviction, or requires multiple helper methods, a class is warranted. Otherwise, the function-based patterns are simpler and preserve the decorated object's type identity.
Building decorators that work with and without parentheses removes a category of errors from your codebase. Users of your decorators never need to check documentation to remember whether the parentheses are required. The overhead of adding the detection pattern is a few lines of boilerplate, and the payoff is a decorator interface that handles every calling convention correctly.
How to Implement a Decorator That Works With and Without Parentheses
- Define the function with
func=Noneas the first parameter. Start withdef decorator_name(func=None, *, option=default). Thefuncparameter will receive the decorated function when parentheses are omitted, or remainNonewhen parentheses are used. The bare*forces all configuration parameters to be keyword-only. - Write the inner decorator and wrapper functions. Inside the outer function, define a
decorator(fn)function that contains a@wraps(fn)wrapper. The wrapper captures the configuration arguments via closure and calls the original function. - Branch on whether
funcisNone. After the inner functions, check iffunc is not None. If it has a value, the decorator was called without parentheses, so returndecorator(func)directly. IffuncisNone, return thedecoratorfunction itself so Python can call it with the function in the next step. - Optionally replace the branch with
functools.partial. For a flatter structure, replace the if/else with a single line: iffuncisNone, returnpartial(decorator_name, **kwargs). The partial object is a callable that will invoke the decorator with the pre-filled arguments when Python passes the decorated function. - Test all three calling conventions. Verify the decorator works with
@decorator(no parentheses),@decorator()(empty parentheses), and@decorator(option=value)(with arguments). All three should produce the same wrapped behavior with the expected configuration.
Frequently Asked Questions
Why do some Python decorators work with and without parentheses?
These decorators inspect their first argument to determine how they were called. When used without parentheses (@decorator), Python passes the decorated function directly. When used with parentheses (@decorator() or @decorator(arg=value)), the decorator is called first and must return a callable that will then receive the function. The decorator detects which case occurred, typically by checking whether the first argument is callable.
How does the callable detection pattern work for optional-parentheses decorators?
The pattern uses a function signature like def decorator(func=None, *, option=default). If func is not None and is callable, the decorator was used without parentheses and should wrap the function directly. If func is None, the decorator was called with arguments and must return a new callable that will receive the function. The * in the signature forces all configuration arguments to be keyword-only, preventing ambiguity.
What is the functools.partial approach for optional-parentheses decorators?
Instead of nested if/else branching, functools.partial pre-fills the decorator's keyword arguments and returns a new callable waiting for the function argument. When func is None (parentheses were used with arguments), the decorator returns partial(decorator_name, **kwargs), which produces a single-argument callable ready to receive the decorated function.
Can class-based decorators support optional parentheses?
Yes. A class-based decorator implements __init__ to accept either the decorated function directly (no parentheses) or configuration arguments (with parentheses), and __call__ to handle the remaining step. When __init__ receives a callable, the decorator stores it and __call__ acts as the wrapper. When __init__ receives only keyword arguments, __call__ receives the function and returns the wrapper.
How does Python's built-in dataclass decorator work without parentheses?
The dataclass decorator uses the same callable detection pattern. Its signature is dataclass(cls=None, *, init=True, repr=True, ...). When used as @dataclass without parentheses, cls receives the class directly. When used as @dataclass(frozen=True) with parentheses, cls is None and the function returns a decorator that will receive the class on the next call.