I spent a long time treating decorators as purely a function-based tool, until I hit a project where I needed a decorator to track state across many different decorated functions — counting calls, caching results per-function, and managing configuration that changed at runtime. Function closures started getting messy fast. That’s when I switched to class-based decorators, and honestly, it changed how I write reusable code in Python.
This guide covers everything I’ve learned about building decorators as classes: how they work internally, when to prefer them over function decorators, and the patterns I actually use in production code.
What Makes a Class a Decorator
In Python, anything callable can be a decorator — a function, a method, or an instance of a class that implements __call__. When you write:
@MyDecorator
def my_func():
pass
Python is really doing this:
def my_func():
pass
my_func = MyDecorator(my_func)
So MyDecorator.__init__ receives the function being decorated, and my_func afterward refers to the instance of MyDecorator, not the original function anymore. When you later call my_func(), Python calls instance.__call__().
A Basic Class-Based Decorator
Here’s the simplest version I start with when teaching this concept:
import functools
class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call #{self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CallCounter
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Ali")
say_hello("Sara")
print(f"Total calls: {say_hello.count}")
Output:
Call #1 to say_hello
Hello, Ali!
Call #2 to say_hello
Hello, Sara!
Total calls: 2
Notice functools.update_wrapper(self, func) — this is the class-based equivalent of functools.wraps, and it copies over __name__, __doc__, and __module__ so the decorated object still looks like the original function during introspection.
Why State Management Is Easier With Classes
With a function-based decorator, tracking state means relying on closures or mutable default arguments — both of which get awkward once you need more than one or two pieces of state. A class gives you self, which is just cleaner for anything beyond a trivial counter.
Here’s a caching example that stores results per-instance:
class MemoizeDecorator:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.cache = {}
def __call__(self, *args):
if args not in self.cache:
self.cache[args] = self.func(*args)
print(f"Computed and cached result for {args}")
else:
print(f"Returned cached result for {args}")
return self.cache[args]
@MemoizeDecorator
def slow_square(n):
import time
time.sleep(1)
return n * n
print(slow_square(4))
print(slow_square(4))
print(slow_square(5))
Output:
Computed and cached result for (4,)
16
Returned cached result for (4,)
16
Computed and cached result for (5,)
25
I like this pattern because self.cache is scoped naturally to the decorated function, and I can add methods later (like clear_cache()) without restructuring anything.
Class-Based Decorators That Accept Arguments
Just like function decorators, class decorators can accept their own configuration arguments — but this time it happens in __init__, and the function being decorated is received by __call__:
class Throttle:
def __init__(self, max_calls):
self.max_calls = max_calls
self.calls = 0
def __call__(self, func):
functools.update_wrapper(self, func)
def wrapper(*args, **kwargs):
if self.calls >= self.max_calls:
raise RuntimeError("Call limit exceeded")
self.calls += 1
return func(*args, **kwargs)
return wrapper
@Throttle(max_calls=2)
def limited_action():
print("Action performed")
limited_action()
limited_action()
try:
limited_action()
except RuntimeError as e:
print(f"Error: {e}")
Output:
Action performed
Action performed
Error: Call limit exceeded
This distinction trips people up: when the class is the decorator directly (no arguments), __init__ gets the function. When the class is instantiated first with arguments and then used as a decorator, __init__ gets the config and __call__ gets the function.
Internal Working: Instances as Callables
The mechanism that makes all of this possible is the __call__ dunder method. Any object with __call__ defined satisfies Python’s notion of “callable,” which is checked by the callable() builtin and used implicitly whenever you write obj(...). Internally, obj() translates to type(obj).__call__(obj, ...) — Python looks up __call__ on the type, not the instance, which matters if you ever try to assign __call__ dynamically to an instance (it won’t work; it must be defined on the class).
This is also why class-based decorators integrate cleanly with the rest of Python’s object model — you can add other methods, properties, and even make the decorator itself support the descriptor protocol if you need it to work properly on class methods.
Handling Instance Methods
A common pitfall: applying a class-based decorator directly to methods can break self binding because the decorator’s __call__ replaces the bound-method behavior. The fix, in most real code I write, is to implement __get__ so the decorator behaves as a proper descriptor:
import functools
class LoggedMethod:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
def __call__(self, *args, **kwargs):
print(f"Calling {self.func.__name__}")
return self.func(*args, **kwargs)
def __get__(self, instance, owner):
if instance is None:
return self
return functools.partial(self.__call__, instance)
class Robot:
@LoggedMethod
def move(self, direction):
print(f"Moving {direction}")
r = Robot()
r.move("north")
Output:
Calling move
Moving north
Without __get__, calling r.move("north") would fail because self (the Robot instance) wouldn’t be passed correctly — this was one of the trickiest bugs I debugged early in my Python career, and understanding descriptors fixed it permanently.
Performance and Memory Considerations
Class-based decorators create one instance per decorated function, and that instance persists for the lifetime of the program (typically for as long as the module is loaded). This is roughly the same memory footprint as a closure-based decorator, since both store references to the original function and any extra state. The difference is negligible in practice — a few extra bytes for the object header — but it’s worth knowing that each decorated function now has its own object identity, which is genuinely useful for debugging (type(my_func) will show CallCounter, for instance, immediately signaling it’s decorated).
Real-World Use Cases
- Stateful caching/memoization where you want a
.cache_clear()method attached. - Rate limiters and circuit breakers for API clients, where the class tracks failure counts.
- Plugin/registry patterns, where decorating a function also registers it into a class-level dictionary.
- Configuration-driven behavior, like
@RetryPolicy(max_attempts=5, backoff=2)for resilient network code. - Building decorators with a rich introspection API, such as exposing
.call_count,.last_result, or.enabledas public attributes.
Best Practices and Common Mistakes
- Always call
functools.update_wrapper(self, func)in__init__to preserve metadata. - Implement
__get__if your decorator is meant to be used on instance methods. - Keep
__call__lightweight — heavy logic belongs in helper methods for readability. - Don’t forget that a class-based decorator instance is created once at decoration time (module load), not once per call — this affects when side effects run.
- Prefer function-based decorators for simple, stateless wrapping; reach for class-based ones when you genuinely need persistent state or a richer API.
Troubleshooting Tips
If you see TypeError: move() missing 1 required positional argument: 'self', you’re likely missing the __get__ descriptor implementation on a method decorator.
If help(my_func) shows the wrong docstring, double check functools.update_wrapper was called.
If your decorator “loses” state between calls unexpectedly, verify you’re not accidentally re-instantiating the class (for example, by re-importing the module in a way that resets it).
FAQs
Can a class-based decorator be stacked with function-based decorators? Yes, freely — Python doesn’t care whether a decorator is a function or a callable object, only that it’s callable.
Does a class decorator need an __init__? If it decorates the function directly (@MyClass), yes — __init__ receives the function. If it’s called with arguments first (@MyClass(x=1)), __init__ receives the arguments and __call__ receives the function.
Is functools.update_wrapper different from functools.wraps? functools.wraps is a decorator built on top of functools.update_wrapper; the latter is the direct function call used for object instances.
Are class-based decorators slower than function-based ones? The difference is negligible for nearly all use cases; both involve one extra callable layer.
Summary
Class-based decorators shine when you need to manage state, expose extra methods, or build richer, more introspectable enhancements around functions. The core idea is simple: decoration is just calling MyClass(func), and later calls to the decorated name invoke __call__ on that instance. Once I understood __call__ and the descriptor protocol together, class-based decorators became one of my favorite tools for building clean, reusable, stateful wrappers.
