Class-Based Python Decorators: When Functions Aren't Enough
Closures hold state. Classes give it a home.

You've built decorators with functions. You've nested them two layers deep, three layers deep, stacked them on top of each other. And it all works.
But at some point, you'll write a decorator that needs to remember things between calls: a counter, a cache, a log of every invocation... And you'll find yourself reaching nonlocal, juggling closure variables, and wondering if there'a a cleaner way.
There is. It's a class.
A Quick Prerequisite: __call__
Before we build anything, you need to know one thing about Python classes: any object can behave like a function if its class defines a __call__ method.
class Greeter:
def __call__(self, name):
print(f"Hello, {name}!")
greet = Greeter()
greet("Moussa") # Hello, Moussa!
greet is not a function, it's an instance of Greeter. But because Greeter has __call__, you can use parentheses on it as if it were a function. Python sees greet("Moussa") and internally calls greet.__call__("Moussa").
This is the entire foundation of class-based decorators. If an object is callable, it can replace a function. And if it can replace a function, it can be a wrapper.
Your First Class-Based Decorator
Let's start with something familiar, a decorator that logs every time a function is called:
Function-based version (what you already know):
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Class-based version:
class LogCalls:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
print(f"Calling {self.func.__name__}")
return self.func(*args, **kwargs)
@LogCalls
def greet(name):
return f"Hello, {name}"
print(greet("Moussa"))
# Calling greet
# Hello, Moussa
Let's trace through what Python does when it sees @LogCalls:
Python calls
@LogsCall(greet): This triggers__init__,which stores the original function asself.funcgreetis now replaced by theLogCallsinstanceWhen you call
greet("Moussa"), Python calls the instance, which triggers__call__Inside
__call__, we run our logic and callself.func("Moussa")
Same pattern as before: take a function in, return something callable that wraps. The difference is that the "something callable" is now an object instead of an inner function.
Why Bother? The State Problem
So far, the class version looks like more code for the same result. Fair enough ๐!
The real advantage shows up when your decorator needs to maintain state.
Let's build a decorator that counts how many times a function has been called.
Function-based version:
def count_calls(func):
count = 0
def wrapper(*args, **kwargs):
nonlocal count
count += 1
print(f"{func.__name__} has been called {count} time(s)")
return func(*args, **kwargs)
return wrapper
It works, but there's a problem: count is trapped inside the closure. You can't access it from outside. You can't reset it. You can't inspect it. It's invisible.
Class-based version
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__} has been called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
return f"Hello, {name}"
greet("Moussa") # greet has been called 1 time(s)
greet("Fabien") # greet has been called 2 time(s)
print(greet.count) # 2 โ accessible!
greet.count = 0 # reset it
greet("Moussa") # greet has been called 1 time(s)
The state lives on self, not inside a closure. You can read it, reset it, and even add methods to interact with it. The decorator becomes a proper object, not a black box.
This is the key insight: function-based decorators use closure to remember state. Class-based decorators use self. Same concept, different container, but self is far more accessible.
Adding Methods to Your Decorators
Since the wrapper is now an object, you can give it useful methods:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
def reset(self):
self.count = 0
def report(self):
print(f"{self.func.__name__} has been called {self.count} time(s)")
@CountCalls
def process_order(order_id):
return f"Order {order_id} processed"
process_order(1)
process_order(2)
process_order(3)
process_order.report() # process_order has been called 3 times
process_order.reset()
process_order.report() # process_order has been called 0 times
Try doing that with a function-based decorator. You'd have to attach functions as attributes on the wrapper, awkward and messy. With a class, it's natural.
Class-Based Decorators with Arguments
Just like function-based decorators, you can make class-based decorators configurable. The pattern shifts slightly.
Without arguments, __init__ receives the arguments, and __call__ receives the function:
@MyDecorator(arg) # Python calls MyDecorator(arg), then the result(func)
def my_function():
pass
Here's a practical example: a decorator that slows down a function by a configurable number of seconds:
import time
class SlowDown:
def __init__(self, seconds):
self.seconds = seconds
def __call__(self, func):
def wrapper(*args, **kwargs):
print(f"Waiting {self.seconds}s before calling {func.__name__}...")
time.sleep(self.seconds)
return func(*args, **kwargs)
return wrapper
@SlowDown(2)
def send_email(to):
print(f"Email sent to {to}")
send_email("moussa@example.com")
# Waiting 2s before calling send_email...
# Email sent to moussa@example.com
Let's trace through it:
Python evaluates
SlowDown(2): This creates an instance withself.seconds = 2Python calls that instance with the function:
instance(send_email), which triggers__call____call__returns thewrapperfunctionsend_emailis now replaced bywrapper
Notice the difference: when there are no arguments, __call__ is the wrapper itself (it gets called every time the decorated function is called). When there are arguments, __call__ is the decorator (it gets called once, receiving the function, and returns a wrapper).
This is the trickiest part of class-based decorators. Read that paragraph again if you need to.
Practical Example: A Rate Limiter
Here's a class-based decorator that limits how often a function can be called:
import time
class RateLimit:
def __init__(self, min_interval):
self.min_interval = min_interval
def __call__(self, func):
last_called = [0] # using list to allow mutation in closure
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < self.min_interval:
wait = self.min_interval - elapsed
print(f"Rate limited. Wait {wait:.1f}s")
return None
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
@RateLimit(min_interval=2)
def call_api(endpoint):
print(f"Calling {endpoint}")
return {"status": "ok"}
call_api("/users") # Calling /users
call_api("/users") # Rate limited. Wait 1.8s
time.sleep(2)
call_api("/users") # Calling /users
The min_interval configuration lives on the instance. The call timing state lives in the closure. This hybrid approach: class for configuration, closure for per-call state, is a common and effective pattern.
Practice Example: A Retry with Exponential Backoff
import time
class Retry:
def __init__(self, max_attempts=3, backoff_factor=2):
self.max_attempts = max_attempts
self.backoff_factor = backoff_factor
def __call__(self, func):
def wrapper(*args, **kwargs):
for attempt in range(1, self.max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == self.max_attempts:
print(f"All {self.max_attempts} attempts failed.")
raise
wait = self.backoff_factor ** attempt
print(f"Attempt {attempt} failed: {e}. Retrying in {wait}s...")
time.sleep(wait)
return wrapper
@Retry(max_attempts=3, backoff_factor=2)
def fetch_data(url):
raise ConnectionError("Server unreachable")
# fetch_data("https://api.example.com")
# Attempt 1 failed: Server unreachable. Retrying in 2s...
# Attempt 2 failed: Server unreachable. Retrying in 4s...
# All 3 attempts failed.
The class makes the configuration readable: max_attempts=3, backoff_factor=2 reads like a sentence. Compare this to the function-based version where these would be arguments to a factory function. Same result, but the class version communicates intent more clearly when the configuration is complex.
When to Use Function-Based vs Class-Based
This isn't a "one is better" situation. They're tools for different contexts:
Use function-based decorators when:
Your decorator is simple: log, time, validate, then call the function
You don't need to maintain state between calls
You don't need to expose any interface to the caller
You want less boilerplate for a quick wrapper
Use class-based decorators when:
You need to maintain state across calls (counters, caches, history)
You want to expose methods or properties on the decorated function (
.reset(),.count,.report())Your decorator has complex configuration with multiple parameters
You want to use inheritance to create decorator families
Readability matters more than brevity, classes make the structure explicit
Most decorators you'll write and encounter are function-based. But when the logic gets complex or stateful, classes keep things organized where closures start to get tangled.
A Note on functools.wraps
One thing we haven't addressed: when you decorate a function with a class, the function's __name__ and __doc__ get replaced by the class's.
@CountCalls
def greet(name):
"""Greet someone by name."""
return f"Hello, {name}"
print(greet.__name__) # 'CountCalls' โ not 'greet'!
print(greet.__doc__) # None โ the docstring is gone!
For function-based decorators, you'd use @functools.wraps(func) on the wrapper.
For class-based decorators, you can apply it in __init__:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
"""Greet someone by name."""
return f"Hello, {name}"
print(greet.__name__) # 'greet' โ preserved!
print(greet.__doc__) # 'Greet someone by name.' โ preserved!
functools.update_wrapper copies the original function's metadata on to the instance. Always do this as it keeps debugging, documentation, and introspection tools working correctly.
The Mental Model
Here's how function-based and class-based decorators map to each other:
Function-based:
Outer function receives the function (or arguments)
Inner function (wrapper) replaces the function
Closure variables are the state
Class-based
__init__receives the function (or arguments)__call__replaces the function (or returns the wrapper)selfrepresents the state.
Same architecture, different syntax. If you understand one, you understand both. The only question is which one keeps your code for the specific problem you're solving.
What's Next?
This article extends the Python Decorators series with a pattern you'll encounter in more advanced codebases, especially in frameworks, ORMs, and testing libraries. If you haven't read the earlier parts, here's the full series:
Part 1: Understanding Python Decorators From Scratch: The foundations
Part 2: The Print vs Return Trap: The most common silent bug
Part 3: Advanced Patterns โ Arguments and Stacking: The real-world patterns
Part 4: 10 Exercises to Master Decorators: Hands-on practice
Thank you for reading ๐.
This is a bonus article in the Python Decorators series on Build, Break, Learn.
Written by a developer who learned the hard way so you don't have to.





