How to Make a Class Instance Behave Like a Callable Decorator

Function-based decorators use closures to wrap behavior around a target function. That works well until you need to track state between calls, expose methods on the decorator itself, or organize complex configuration logic. At that point, a class whose instances are callable through the __call__ method becomes the cleaner alternative. This article covers the full pattern for building class-based decorators in Python, from the basic __init__ / __call__ structure through preserving metadata, handling method decoration, and adding configuration arguments.

Why __call__ Makes a Class Instance Callable

In Python, any object with a __call__ method is callable. You can verify this with the built-in callable() function:

class Greeter:
    def __init__(self, greeting):
        self.greeting = greeting

    def __call__(self, name):
        return f"{self.greeting}, {name}!"


hello = Greeter("Hello")
print(callable(hello))
print(hello("reader"))
Output True Hello, reader!

The instance hello behaves like a function. You call it with parentheses and arguments, and Python routes the call to __call__. Because a decorator is any callable that takes a function and returns a callable, a class with __call__ can serve as a decorator.

The Basic Class Decorator Pattern

The simplest class-based decorator receives the function in __init__ and wraps it in __call__:

class LogCalls:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        result = self.func(*args, **kwargs)
        print(f"{self.func.__name__} returned {result}")
        return result


@LogCalls
def add(a, b):
    return a + b


print(add(3, 5))
Output Calling add add returned 8 8

When Python encounters @LogCalls, it calls LogCalls(add), creating an instance. That instance replaces add in the module namespace. Every subsequent call to add(3, 5) actually calls LogCalls.__call__(self, 3, 5).

Preserving Metadata with update_wrapper

After decoration, add.__name__ returns "LogCalls" instead of "add" because the name now belongs to the class instance. Use functools.update_wrapper to copy the original function's metadata onto the instance:

import functools


class LogCalls:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        return self.func(*args, **kwargs)


@LogCalls
def multiply(a, b):
    """Multiply two numbers."""
    return a * b


print(multiply.__name__)
print(multiply.__doc__)
print(multiply.__wrapped__)
Output multiply Multiply two numbers. <function multiply at 0x...>
Note

For function-based decorators, you use @functools.wraps(func) on the inner wrapper. For class-based decorators, you call functools.update_wrapper(self, func) in __init__ because the class instance itself is the wrapper. They do the same thing — the syntax differs because wraps is a decorator and update_wrapper is a regular function call.

Stateful Decorators: Tracking Data Across Calls

The primary advantage of class-based decorators over closures is clean, readable state management. Instance attributes persist across calls naturally:

import functools
import time


class Timer:
    def __init__(self, func):
        self.func = func
        self.total_time = 0.0
        self.call_count = 0
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        start = time.perf_counter()
        result = self.func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        self.total_time += elapsed
        self.call_count += 1
        return result

    def stats(self):
        avg = self.total_time / self.call_count if self.call_count else 0
        return {
            "calls": self.call_count,
            "total_seconds": round(self.total_time, 6),
            "avg_seconds": round(avg, 6),
        }


@Timer
def compute(n):
    return sum(range(n))


compute(1_000_000)
compute(2_000_000)
compute(500_000)

print(compute.stats())
Output {'calls': 3, 'total_seconds': 0.089341, 'avg_seconds': 0.029780}

The stats() method is accessible directly on the decorated function because compute is a Timer instance. With a closure-based decorator, exposing extra methods requires attaching them to the wrapper function, which is less natural.

Parameterized Class Decorators

When the decorator needs configuration arguments, the class structure changes. The __init__ receives the configuration, and __call__ receives the function. The __call__ method returns a wrapper function rather than calling the function directly:

import functools
import time


class Retry:
    def __init__(self, max_attempts=3, delay=1.0):
        self.max_attempts = max_attempts
        self.delay = delay

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, self.max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    print(f"  Attempt {attempt} failed: {e}")
                    if attempt < self.max_attempts:
                        time.sleep(self.delay)
            raise last_exception
        return wrapper


@Retry(max_attempts=3, delay=0.1)
def fetch_data(url):
    """Fetch data from a remote endpoint."""
    raise ConnectionError(f"Cannot reach {url}")


print(fetch_data.__name__)

try:
    fetch_data("https://api.example.com/data")
except ConnectionError:
    print("All attempts exhausted")
Output fetch_data Attempt 1 failed: Cannot reach https://api.example.com/data Attempt 2 failed: Cannot reach https://api.example.com/data Attempt 3 failed: Cannot reach https://api.example.com/data All attempts exhausted

The flow here is different from the non-parameterized version. @Retry(max_attempts=3, delay=0.1) first creates a Retry instance with the configuration. Python then calls that instance with fetch_data as the argument, which invokes __call__(self, func). The method returns the wrapper function, which replaces fetch_data in the namespace.

Pattern__init__ Receives__call__ Receives__call__ Returns
Non-parameterizedThe functionCall-time argsThe function's return value
ParameterizedConfiguration argsThe functionA wrapper function

The Method Problem and the Descriptor Protocol

A non-parameterized class-based decorator has a known limitation: it does not work correctly on class methods. When a class instance replaces a function, it loses the ability to bind to the object it is called on. The self argument of the method never gets passed:

class LogCalls:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        return self.func(*args, **kwargs)


class Calculator:
    @LogCalls
    def add(self, a, b):
        return a + b


c = Calculator()
try:
    c.add(2, 3)
except TypeError as e:
    print(f"Error: {e}")
Output Calling add Error: Calculator.add() missing 1 required positional argument: 'b'

The problem is that LogCalls instances are not descriptors. When Python looks up c.add, it finds a LogCalls instance but does not invoke the descriptor protocol to bind c as the first argument. You can fix this by implementing __get__:

import functools
from types import MethodType


class LogCalls:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__}")
        return self.func(*args, **kwargs)

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return MethodType(self, obj)


class Calculator:
    @LogCalls
    def add(self, a, b):
        return a + b


c = Calculator()
print(c.add(2, 3))
Output Calling add 5

The __get__ method makes the LogCalls instance participate in the descriptor protocol. When c.add is accessed, Python calls LogCalls.__get__(self, c, Calculator), which returns a bound method. The bound method prepends c as the first argument when called, so self is correctly passed to Calculator.add.

Critical

If your class-based decorator will ever be used on methods (not just standalone functions), you must implement __get__. Without it, the decorator silently receives the wrong number of arguments.

Returning a Wrapper Instead of Using __call__ Directly

An alternative to implementing __get__ is to have __call__ return a regular wrapper function instead of calling the stored function directly. Because functions are descriptors natively, the returned wrapper handles method binding automatically:

import functools


class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"[{self.func.__name__}] call #{self.count}")
        return self.func(*args, **kwargs)


# Alternative: return a wrapper from __init__ via __call__ acting as factory
class CountCallsSafe:
    def __init__(self, func):
        self.count = 0
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            self.count += 1
            print(f"[{func.__name__}] call #{self.count}")
            return func(*args, **kwargs)
        self.wrapper = wrapper

    def __call__(self, *args, **kwargs):
        return self.wrapper(*args, **kwargs)

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        import types
        return types.MethodType(self, obj)

Both approaches work. The first is more direct but requires __get__ for method compatibility. The second approach stores a closure-based wrapper and delegates to it, combining the class's state management with the closure's native descriptor behavior.

Exposing Decorator Methods to Callers

One of the strongest reasons to use a class-based decorator is that callers can interact with the decorator instance through its methods. A Timer decorator can expose stats() and reset(). A Cache decorator can expose cache_clear() and cache_info():

import functools


class Memoize:
    def __init__(self, func):
        self.func = func
        self.cache = {}
        functools.update_wrapper(self, func)

    def __call__(self, *args):
        if args in self.cache:
            return self.cache[args]
        result = self.func(*args)
        self.cache[args] = result
        return result

    def cache_clear(self):
        self.cache.clear()

    def cache_info(self):
        return {"size": len(self.cache)}


@Memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(10))
print(fibonacci.cache_info())

fibonacci.cache_clear()
print(fibonacci.cache_info())
Output 55 {'size': 11} {'size': 0}

The fibonacci name points to a Memoize instance, so fibonacci.cache_clear() and fibonacci.cache_info() are natural method calls. With a closure-based decorator, you would need to attach these as attributes on the wrapper function, which feels less cohesive.

When to Choose Classes Over Closures

ConsiderationFunction ClosureClass-Based
Simple wrapper, no statePreferred — less codeUnnecessary overhead
Tracking calls, timing, cachingWorks but awkward with nonlocalClean instance attributes
Exposing methods on the decoratorPossible but feels bolted onNatural method interface
Decorating methods on classesWorks natively (functions are descriptors)Requires __get__ implementation
Multiple related decoratorsSeparate functionsInheritance or shared base class
Readability for complex logicDeep nestingFlat class structure
Pro Tip

If you are unsure, start with a function-based decorator. Refactor to a class when you find yourself using nonlocal to track mutable state across calls, or when you want the decorator to have its own public API (like reset() or stats()).

Key Takeaways

  1. __call__ makes instances callable: Any object with a __call__ method can be used as a decorator because decorators are just callables that take a function and return a callable.
  2. Non-parameterized: __init__ gets the function, __call__ wraps it: The class instance replaces the function in the namespace. Every call to the decorated function routes through __call__.
  3. Parameterized: __init__ gets config, __call__ gets the function: The decorator expression @Class(args) creates an instance, then Python calls that instance with the function. __call__ returns a wrapper.
  4. Use functools.update_wrapper for metadata: Call functools.update_wrapper(self, func) in __init__ to copy __name__, __doc__, __module__, and __wrapped__ to the instance.
  5. Implement __get__ for method compatibility: Without the descriptor protocol, class-based decorators do not correctly bind the instance argument when used on methods. Implement __get__ to return a MethodType bound to the object.
  6. Class decorators excel at stateful behavior: Call counters, execution timers, caches, and rate limiters are all naturally expressed as class attributes. The decorator instance can also expose methods like stats(), reset(), and cache_clear() that callers can use directly.

Class-based decorators are not a replacement for function-based decorators — they are an alternative for cases where persistent state and a public method interface make the code clearer. The __call__ method is the bridge that lets an object behave like a function, and once you add functools.update_wrapper and __get__, the resulting decorator is production-ready.

Frequently Asked Questions

How do I make a class work as a decorator in Python?

Implement the __call__ method on the class. For a simple decorator, __init__ receives the function being decorated and stores it as an instance attribute. __call__ receives the call-time arguments, executes logic around the stored function, and returns its result. The class instance replaces the original function in the namespace.

When should I use a class-based decorator instead of a function?

Use a class-based decorator when you need to maintain state across multiple calls to the decorated function, when the decorator has complex configuration that benefits from instance attributes, or when you want the decorator itself to expose methods that callers can interact with (like resetting a counter or clearing a cache).

How do I preserve function metadata in a class-based decorator?

Use functools.update_wrapper(self, func) inside __init__, or if your __call__ returns a wrapper function, use @functools.wraps(func) on that wrapper. The update_wrapper function copies __name__, __doc__, __module__, __qualname__, and __dict__ from the original function to the class instance.

Why does a class-based decorator fail when decorating a method?

Class instances are not descriptors by default, so they do not bind to the instance when accessed as method attributes. The self argument of the method never gets passed. To fix this, implement the __get__ method on the decorator class so it returns a bound version of itself using types.MethodType.