Python decorators that accept arguments can be written as either triple-nested functions or as classes that implement __init__ and __call__. Both approaches produce the same external behavior: the decorator accepts configuration, wraps a function, and returns a modified callable. The difference is internal. Function-based decorators use closures for state. Class-based decorators use instance attributes. This article builds the same decorator both ways, adds stateful behavior, demonstrates inheritance, and provides a clear decision framework for choosing between them.
A decorator is any callable that accepts a function and returns a callable. Functions are callable. Instances of classes that implement __call__ are also callable. Python does not care which one you use. The @ syntax calls whatever follows it, and as long as that callable returns something callable, the decoration succeeds. This flexibility is what makes class-based decorators possible and why they are interchangeable with function-based ones from the caller's perspective.
The Same Decorator, Two Ways
To make the comparison concrete, here is a log_calls decorator that accepts a level parameter and prints a message before and after the decorated function runs. First, the function-based version:
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
def log_calls(level: str = "INFO") -> Callable[[Callable[..., Any]], Callable[..., Any]]: # Level 1: factory
def decorator(func: Callable[..., Any]) -> Callable[..., Any]: # Level 2: decorator
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any: # Level 3: wrapper
print(f"[{level}] Entering {func.__name__}")
result = func(*args, **kwargs)
print(f"[{level}] Exiting {func.__name__}")
return result
return wrapper
return decorator
@log_calls(level="DEBUG")
def process_data(payload: dict[str, Any]) -> dict[str, Any]:
return {"processed": payload}
print(process_data({"key": "value"}))
# [DEBUG] Entering process_data
# [DEBUG] Exiting process_data
# {'processed': {'key': 'value'}}
Now the same behavior implemented as a class:
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
class LogCalls:
def __init__(self, level: str = "INFO") -> None: # Accepts parameters
self.level = level
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: # Accepts the function
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
print(f"[{self.level}] Entering {func.__name__}")
result = func(*args, **kwargs)
print(f"[{self.level}] Exiting {func.__name__}")
return result
return wrapper
@LogCalls(level="DEBUG")
def process_data(payload: dict[str, Any]) -> dict[str, Any]:
return {"processed": payload}
print(process_data({"key": "value"}))
# [DEBUG] Entering process_data
# [DEBUG] Exiting process_data
# {'processed': {'key': 'value'}}
The output is identical. The usage syntax is identical. The difference is structural: the function version uses three nested def statements with level captured in a closure. The class version uses __init__ to store level as an instance attribute and __call__ to accept the function and return the wrapper. The class has two levels of visual nesting instead of three.
How the Class-Based Mechanism Works
When Python encounters @LogCalls(level="DEBUG"), it evaluates LogCalls(level="DEBUG") first. This creates an instance of LogCalls by calling __init__ with level="DEBUG". The instance stores self.level = "DEBUG". Python then calls the instance with the decorated function: instance(process_data). This triggers __call__, which receives process_data as func, creates the wrapper, and returns it. The name process_data is rebound to the wrapper.
# What Python does behind the scenes:
# Step 1: Create the instance (calls __init__)
instance = LogCalls(level="DEBUG")
# Step 2: Call the instance with the function (calls __call__)
process_data = instance(process_data)
# Combined:
# process_data = LogCalls(level="DEBUG")(process_data)
This two-step process mirrors the function-based factory pattern, where log_calls(level="DEBUG") returns a decorator, and the decorator is called with the function. The class-based version makes the two steps more explicit: construction is separate from call.
The key structural difference: in a function-based decorator with arguments, the parameters live in a closure. In a class-based decorator, the parameters live in self. Both are accessible inside the wrapper, but self attributes are visible to external code and testing tools, while closure variables are not.
__init__ with level="DEBUG". A LogCalls instance is created. self.level = "DEBUG" is stored. The instance — not a function — is returned and sits waiting.@ syntax applies the result of step 1 to the function being decorated. Because the instance implements __call__, this is equivalent to instance.__call__(process_data).func is now bound to the original process_data. The wrapper closure captures both self (the instance) and func. __call__ returns wrapper.process_data in the module namespace now points to wrapper, not the original function. Every subsequent call to process_data() runs wrapper(), which reads self.level and calls the original via func().Think of a function-based decorator factory as a vending machine: you insert the configuration (coins), it dispenses a specific decorator (item). The machine is then gone — you can't ask it what it dispensed. A class-based decorator is more like a named template object: you build it once with configuration, stamp it onto as many functions as you like, and the template object sits on your shelf, inspectable and adjustable, for the entire life of the program.
You create one CountCalls instance and decorate two different functions with it:counter = CountCalls(threshold=3)
@counter
def read(): ...
@counter
def write(): ...
You call read() twice, then write() once. What is counter.call_count?
A class-based decorator omits @functools.wraps(func) from its wrapper. Which of the following is a concrete, observable consequence — not just a theoretical concern?
RetryTimedDecorator subclasses TimedDecorator and overrides only process(). It does not override __call__. When a decorated function is called, in what order do the class bodies actually execute?
Stateful Decorators: Where Classes Excel
Decorators that need to track state across invocations expose the clearest difference between the two approaches. A call counter that records how many times each decorated function has been called illustrates this well.
Function-Based: State via nonlocal
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
def count_calls(threshold: int = 10) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
call_count = 0
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
nonlocal call_count
call_count += 1
if call_count > threshold:
print(
f"[WARN] {func.__name__} called {call_count} times "
f"(threshold: {threshold})"
)
return func(*args, **kwargs)
return wrapper
return decorator
@count_calls(threshold=3)
def fetch(url: str) -> str:
return f"Response from {url}"
for i in range(5):
fetch("https://api.example.com")
# [WARN] fetch called 4 times (threshold: 3)
# [WARN] fetch called 5 times (threshold: 3)
The call_count variable lives inside the decorator closure. The nonlocal keyword is required to allow the wrapper to modify it. This works, but the counter is invisible from outside: there is no way to read call_count without calling the function.
Class-Based: State via Instance Attributes
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
class CountCalls:
def __init__(self, threshold: int = 10) -> None:
self.threshold = threshold
self.call_count = 0
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
self.call_count += 1
if self.call_count > self.threshold:
print(
f"[WARN] {func.__name__} called {self.call_count} times "
f"(threshold: {self.threshold})"
)
return func(*args, **kwargs)
return wrapper
def reset(self) -> None:
"""Reset the counter to zero."""
self.call_count = 0
def __repr__(self) -> str:
return (
f"CountCalls(threshold={self.threshold}, "
f"call_count={self.call_count})"
)
counter = CountCalls(threshold=3)
@counter
def fetch(url: str) -> str:
return f"Response from {url}"
for i in range(5):
fetch("https://api.example.com")
# [WARN] fetch called 4 times (threshold: 3)
# [WARN] fetch called 5 times (threshold: 3)
# The state is visible and controllable from outside
print(f"Total calls: {counter.call_count}") # Total calls: 5
counter.reset()
print(f"After reset: {counter.call_count}") # After reset: 0
The class version exposes call_count as a public attribute and provides a reset() method. External code can read the counter, reset it, or modify the threshold without accessing internal closure variables. The decorator instance counter is a first-class object that can be passed around, inspected, and tested independently.
When using a class-based decorator, assign the instance to a variable before applying it with @. This gives you a reference to the instance for later inspection: counter = CountCalls(threshold=3) followed by @counter.
The developer wants a class-based decorator that accepts a prefix argument and logs each function call. The code runs without a syntax error, but the decorator does not behave correctly when used with the @ syntax. Read the code carefully.
What is the bug?
Extending Decorators Through Inheritance
Class-based decorators can be subclassed, which allows you to create specialized decorators from a general-purpose base. Function-based decorators do not support inheritance because functions cannot be subclassed. If you are weighing subclassing against other structural options, the article on composition vs. inheritance in Python covers when each approach serves better in practice.
from __future__ import annotations
import time
from collections.abc import Callable
from functools import wraps
from typing import Any
class TimedDecorator:
"""Base class: times the decorated function."""
def __init__(self, label: str = "TIMER") -> None:
self.label = label
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
start = time.perf_counter()
result = self.process(func, *args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[{self.label}] {func.__name__}: {elapsed:.4f}s")
return result
return wrapper
def process(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""Override this in subclasses to add behavior."""
return func(*args, **kwargs)
class RetryTimedDecorator(TimedDecorator):
"""Extends TimedDecorator with retry logic."""
def __init__(self, max_retries: int = 3, label: str = "RETRY") -> None:
super().__init__(label=label)
self.max_retries = max_retries
def process(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
last_exc: BaseException | None = None
for attempt in range(1, self.max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
print(f" Attempt {attempt}/{self.max_retries} failed: {exc}")
if last_exc is not None:
raise last_exc
raise RuntimeError("max_retries must be >= 1")
# Base decorator: just timing
@TimedDecorator(label="PERF")
def fast_operation() -> int:
return sum(range(10_000))
# Extended decorator: timing + retry
_call_count = [0] # mutable cell avoids global state in the demo
@RetryTimedDecorator(max_retries=3, label="NET")
def flaky_request() -> dict[str, str]:
_call_count[0] += 1
if _call_count[0] < 3:
raise ConnectionError("Network timeout")
return {"status": "ok"}
print(fast_operation())
# [PERF] fast_operation: 0.0003s
# 49995000
print(flaky_request())
# Attempt 1/3 failed: Network timeout
# Attempt 2/3 failed: Network timeout
# [NET] flaky_request: 0.0001s
# {'status': 'ok'}
RetryTimedDecorator inherits the timing logic from TimedDecorator and overrides only the process method to add retry behavior. The timing wraps the entire retry sequence. To achieve this with function-based decorators, you would need to either duplicate the timing code inside the retry decorator or chain two separate decorators. Inheritance keeps the logic centralized in one place and avoids duplication.
Testing and Inspecting Decorator State
Class-based decorators are easier to test because their state is accessible through instance attributes. You can create an instance, inspect its configuration, apply it to a test function, call the function, and then verify the state changed as expected:
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
class CountCalls:
def __init__(self, threshold: int = 10) -> None:
self.threshold = threshold
self.call_count = 0
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
self.call_count += 1
return func(*args, **kwargs)
return wrapper
def reset(self) -> None:
self.call_count = 0
# Testing the decorator independently
def test_count_calls_tracks_invocations() -> None:
counter = CountCalls(threshold=5)
@counter
def dummy() -> str:
return "ok"
assert counter.call_count == 0
dummy()
assert counter.call_count == 1
dummy()
dummy()
assert counter.call_count == 3
counter.reset()
assert counter.call_count == 0
print("All assertions passed")
test_count_calls_tracks_invocations()
# All assertions passed
With a function-based decorator, the call_count variable is trapped inside a closure. You cannot access it without modifying the decorator's code to attach it to the wrapper function as an attribute, which is an extra step that the class-based approach handles naturally.
Decision Framework
__call__ + wrapper)nonlocalself.x)class, __init__, __call__)Use a function-based decorator when the decorator is stateless or has simple, immutable configuration. Logging, timing, access control checks, and argument validation fall into this category. The three-level nesting is worth it for the conciseness and familiarity.
Use a class-based decorator when the decorator needs mutable state that changes across calls (counters, caches, rate-limiting windows), when you want to provide control methods (reset(), clear_cache()), when you need to subclass the decorator for specialized behavior, or when you want to inspect the decorator's configuration and state in tests without calling the decorated function.
Class-based decorators with arguments require careful attention to the __init__ / __call__ split. If you accidentally accept the function in __init__ and the arguments in __call__, the decorator will fail when used with the @ syntax. Parameters always go in __init__. The function always goes in __call__.
How to Build a Class-Based Decorator with Arguments
The following steps show the exact structure Python requires for a class-based decorator that accepts configuration arguments. Each step maps to a specific part of the class body.
- Define the class and accept arguments in
__init__. Write an__init__method that takes your configuration parameters and stores them as instance attributes usingself. Do not accept the function to be decorated at this stage. The function comes later, in__call__. - Accept the decorated function in
__call__. Write a__call__method that takes the function as its argument. This is the method Python invokes when it applies the decorator to a target function. At this point,selfalready holds the configuration from step one. - Define and return the wrapper inside
__call__. Inside__call__, define an inner function decorated with@functools.wraps(func). The wrapper reads configuration fromselfand calls the original function with the received arguments. Return the wrapper from__call__. - Apply the decorator with the
@syntax. Use@ClassName(arg=value)above the target function. Python creates an instance by calling__init__, then immediately calls that instance with the function by invoking__call__. The result is that the function name is rebound to the wrapper. - Add control methods as needed. Because state lives in instance attributes, you can add methods such as
reset()orclear_cache()directly on the class. External code and test suites can call these methods to inspect or modify decorator state without calling the decorated function itself.
Frequently Asked Questions
How does a class-based decorator with arguments work?
A class-based decorator with arguments uses __init__ to accept configuration parameters and __call__ to accept the decorated function. When Python encounters @MyDecorator(arg), it first calls MyDecorator(arg), which creates an instance and runs __init__. Python then calls that instance with the target function, triggering __call__. Inside __call__, you define and return the wrapper.
What is the main advantage of class-based decorators over function-based ones?
Class-based decorators store state in instance attributes, which makes mutable state such as call counters, timestamps, and caches straightforward to manage and accessible to external code. Function-based decorators require nonlocal variables or mutable container objects to achieve the same, which is less explicit and harder to inspect during testing.
Do class-based decorators need functools.wraps?
Yes. The wrapper function returned from __call__ replaces the decorated function in the namespace. Without @functools.wraps(func) on the wrapper, the decorated function loses its __name__, __doc__, and other metadata — the same consequence as omitting it in a function-based decorator.
Can a class-based decorator be extended through inheritance?
Yes. You can create a base decorator class that handles common logic, then subclass it to create specialized variants. Function-based decorators cannot be subclassed because they are functions, not classes. The inheritance section above shows a working example using a base TimedDecorator and a subclass that adds retry logic.
When should I choose a function-based decorator over a class-based one?
Use function-based decorators for stateless, simple modifications such as logging a function call, timing execution, or validating arguments. They require less boilerplate and are the idiomatic choice for straightforward cases. Reserve class-based decorators for situations where you need mutable state, inheritance, or the ability to inspect and control the decorator as an object.
Key Takeaways
- Both approaches produce identical external behavior. A class-based decorator with
__init__/__call__and a function-based triple-nested factory are interchangeable from the caller's perspective. The choice is about internal structure. - Class-based decorators store state in instance attributes. This makes mutable state like counters, caches, and timestamps straightforward to manage, visible to external code, and easy to test. Function-based decorators require
nonlocalvariables for the same purpose. - Class-based decorators support inheritance. You can build a base decorator class and subclass it for specialized behavior. Function-based decorators cannot be subclassed.
- Function-based decorators are shorter for stateless cases. When the decorator just logs, times, or validates without tracking anything across calls, the function-based approach is more concise and more familiar to Python readers.
- Always use
@functools.wraps(func)in both approaches. The wrapper function returned from either style replaces the original function. Without@wraps, the decorated function loses its metadata regardless of whether the decorator is a function or a class. See the full guide on functools.wraps in class-based decorators for detailed examples of what breaks without it.
The two approaches to writing decorators with arguments are not competing philosophies. They are tools with different strengths. Function-based decorators are the default for simple, stateless wrappers. Class-based decorators step in when the decorator needs to be an object in its own right, with state, methods, and the ability to participate in an inheritance hierarchy. Knowing both patterns lets you choose the right tool based on the complexity of the behavior you are implementing.