Skip to main content

Command Palette

Search for a command to run...

Class-Based Python Decorators: When Functions Aren't Enough

Closures hold state. Classes give it a home.

Published
โ€ข9 min read
Class-Based Python Decorators: When Functions Aren't Enough
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back โ€” every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

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:

  1. Python calls @LogsCall(greet): This triggers __init__, which stores the original function as self.func

  2. greet is now replaced by the LogCalls instance

  3. When you call greet("Moussa"), Python calls the instance, which triggers __call__

  4. Inside __call__, we run our logic and call self.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:

  1. Python evaluates SlowDown(2): This creates an instance with self.seconds = 2

  2. Python calls that instance with the function: instance(send_email), which triggers __call__

  3. __call__ returns the wrapper function

  4. send_email is now replaced by wrapper

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)

  • self represents 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:

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.

Python Decorators From the Ground Up

Part 1 of 5

A 4-part series that builds your understanding of Python decorators from the ground up: no prior knowledge assumed. We start with functions as objects, work through closures and wrappers, tackle the print vs return trap that silently breaks your code, explore decorators with arguments and stacking, and finish with 10 hands-on exercises. Written by a developer who learned the hard way so you don't have to.

Up next

10 Exercises to Master Python Decorators (With Solutions)

The part where reading stops and building starts.