Class-Based Decorator with Arguments vs Function Decorator

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.

Note

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.

Execution trace: what Python does with @LogCalls(level="DEBUG")
1
Python evaluates LogCalls(level="DEBUG")
This calls __init__ with level="DEBUG". A LogCalls instance is created. self.level = "DEBUG" is stored. The instance — not a function — is returned and sits waiting.
2
Python calls instance(process_data)
The @ 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).
3
__call__ builds and returns wrapper
func is now bound to the original process_data. The wrapper closure captures both self (the instance) and func. __call__ returns wrapper.
4
process_data is rebound to wrapper
The name 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().
Mental model: factory vs blueprint

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.

CHECK YOUR UNDERSTANDING Predict the behavior

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?

// quiz complete

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.

Pro Tip

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.

SPOT THE BUG Something is wrong with this class-based decorator

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

Function-Based3 levels (factory, decorator, wrapper)
Class-Based2 levels (__call__ + wrapper)
Function-BasedClosure + nonlocal
Class-BasedInstance attributes (self.x)
Function-BasedHidden in closure
Class-BasedPublic on instance
Function-BasedNot possible
Class-BasedFull subclassing support
Function-BasedMust call function to observe effects
Class-BasedInspect instance attributes directly
Function-BasedLess code for stateless decorators
Class-BasedMore boilerplate (class, __init__, __call__)
Function-BasedStandard, expected by readers
Class-BasedLess common, may surprise readers
Function-BasedRequires attaching to wrapper
Class-BasedNatural as instance methods

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.

Warning

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.

  1. Define the class and accept arguments in __init__. Write an __init__ method that takes your configuration parameters and stores them as instance attributes using self. Do not accept the function to be decorated at this stage. The function comes later, in __call__.
  2. 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, self already holds the configuration from step one.
  3. Define and return the wrapper inside __call__. Inside __call__, define an inner function decorated with @functools.wraps(func). The wrapper reads configuration from self and calls the original function with the received arguments. Return the wrapper from __call__.
  4. 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.
  5. Add control methods as needed. Because state lives in instance attributes, you can add methods such as reset() or clear_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

  1. 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.
  2. 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 nonlocal variables for the same purpose.
  3. Class-based decorators support inheritance. You can build a base decorator class and subclass it for specialized behavior. Function-based decorators cannot be subclassed.
  4. 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.
  5. 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.