Differentiating How a Class-Based Decorator Handles the self Argument in Instance Methods

A class-based decorator that works perfectly on standalone functions will produce a TypeError: missing 1 required positional argument: 'self' the moment you apply it to an instance method. This is not a bug in your decorator. It is a consequence of how Python turns plain functions into bound methods through the descriptor protocol -- a mechanism that class-based decorators bypass unless you explicitly implement it. This article explains the problem from the ground up, shows why function-based decorators do not have this issue, and walks through every approach for fixing it in class-based decorators.

To understand the problem, you first need to understand what Python does when you access a method on an instance. When you write obj.method(), Python does not simply look up a function and call it. It invokes the descriptor protocol, which transforms the function into a bound method with self pre-filled. This transformation is the critical step that class-based decorators inadvertently disable.

Why Function-Based Decorators Handle self Automatically

A function-based decorator returns a plain function. Python functions implement __get__ as part of their type, which means they participate in the descriptor protocol. When Python finds a function in a class's attribute dictionary and you access it through an instance, function.__get__ fires and produces a bound method with the instance pre-filled as the first argument.

import functools

def log_calls(func):
    """Function-based decorator -- works on methods automatically."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

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

    @log_calls
    def hello(self):
        return f"Hello from {self.name}"

g = Greeter("Alice")
print(g.hello())
# Calling hello
# Hello from Alice

This works because log_calls returns wrapper, which is a regular function. When Python looks up g.hello, it finds wrapper in Greeter.__dict__, calls wrapper.__get__(g, Greeter), and produces a bound method. The bound method, when called, passes g as the first argument to wrapper. Inside wrapper, *args captures (g,), and func(*args, **kwargs) calls the original hello with self=g.

The key insight: function-based decorators work on instance methods for free because the wrapper is a function, and functions are descriptors.

Why Class-Based Decorators Break on Instance Methods

A class-based decorator replaces the function with an instance of the decorator class. That instance is not a function. Unless the decorator class defines __get__, it is not a descriptor. When Python looks up the attribute on an instance, it finds the decorator instance but has no mechanism to bind self.

class LogCalls:
    """Class-based decorator -- BROKEN on instance methods."""
    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 Greeter:
    def __init__(self, name):
        self.name = name

    @LogCalls
    def hello(self):
        return f"Hello from {self.name}"

g = Greeter("Alice")

# Works fine on standalone functions:
@LogCalls
def standalone():
    return "standalone works"
print(standalone())  # Calling standalone \n standalone works

# Fails on instance methods:
print(g.hello())
# Calling hello
# TypeError: Greeter.hello() missing 1 required positional argument: 'self'

Here is exactly what happens step by step when g.hello() is called:

  1. Python looks up "hello" in g.__dict__. It is not there.
  2. Python looks up "hello" in Greeter.__dict__. It finds a LogCalls instance.
  3. Python checks whether the LogCalls instance is a descriptor (has __get__). It does not.
  4. Python returns the LogCalls instance directly. No binding occurs.
  5. The caller invokes it with (). LogCalls.__call__ fires with args=() and kwargs={}.
  6. self.func(*args, **kwargs) calls the original hello() with no arguments.
  7. hello expects self as its first argument. It gets nothing. TypeError.

The self inside LogCalls.__call__ refers to the LogCalls instance, not the Greeter instance. The Greeter instance is never passed anywhere.

Warning

This bug is subtle because the decorator works correctly on standalone functions. The TypeError only appears when the decorator is used on instance methods. If your tests only cover standalone function usage, the bug goes undetected until the decorator is applied inside a class.

The Fix: Implementing __get__

The fix is to make the decorator class a descriptor by implementing __get__. When Python accesses the decorated method on an instance, __get__ intercepts the lookup and returns a callable with the instance already bound.

import functools

class LogCalls:
    """Class-based decorator -- works on both functions and methods."""
    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:
            # Accessed through the class, not an instance
            return self
        # Accessed through an instance: bind obj as the first argument
        return functools.partial(self.__call__, obj)

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

    @LogCalls
    def hello(self):
        return f"Hello from {self.name}"

g = Greeter("Alice")
print(g.hello())
# Calling hello
# Hello from Alice

# Still works on standalone functions:
@LogCalls
def standalone():
    return "standalone works"
print(standalone())
# Calling standalone
# standalone works

Now when Python looks up g.hello, it finds the LogCalls instance in Greeter.__dict__, sees that it has __get__, and calls LogCalls.__get__(g, Greeter). Because obj is not None (it is the Greeter instance), __get__ returns functools.partial(self.__call__, g). When this partial is called with (), it invokes self.__call__(g), which calls self.func(g), which calls hello(g) -- exactly what was needed.

Alternative: Using types.MethodType

Instead of functools.partial, you can use types.MethodType to create a proper bound method. This is closer to what Python does internally for regular functions:

import types

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
        # Create a bound method: obj is pre-bound as first arg to __call__
        return types.MethodType(self, obj)

When types.MethodType(self, obj) is called, the resulting bound method, when invoked, calls self.__call__(obj, ...). This produces the same behavior as the functools.partial approach but creates an object that inspect recognizes as a bound method.

Pro Tip

Use functools.partial when simplicity is the priority. Use types.MethodType when you need the result to pass inspect.ismethod() checks or when downstream code specifically expects a bound method object.

Building a Universal Class-Based Decorator

A robust class-based decorator should work on standalone functions, instance methods, and methods called through the class. Here is a complete template that handles all three:

import functools
import time

class Timer:
    """Decorator that measures execution time.
    Works on standalone functions, instance methods, and classmethods.
    """
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)
        self.total_time = 0.0
        self.call_count = 0

    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
        print(f"{self.func.__name__} took {elapsed:.6f}s "
              f"(total: {self.total_time:.6f}s over {self.call_count} calls)")
        return result

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return functools.partial(self.__call__, obj)

# Works on standalone functions
@Timer
def compute(n):
    return sum(range(n))

compute(1_000_000)
compute(2_000_000)
# compute took 0.021234s (total: 0.021234s over 1 calls)
# compute took 0.042891s (total: 0.064125s over 2 calls)

# Works on instance methods
class DataProcessor:
    def __init__(self, data):
        self.data = data

    @Timer
    def process(self):
        return sorted(self.data)

    @Timer
    def summarize(self):
        return {
            "count": len(self.data),
            "sum": sum(self.data),
            "mean": sum(self.data) / len(self.data),
        }

import random
dp = DataProcessor([random.randint(0, 10000) for _ in range(100_000)])
dp.process()
dp.summarize()
# process took 0.012345s (total: 0.012345s over 1 calls)
# summarize took 0.003456s (total: 0.003456s over 1 calls)

This Timer class maintains state (total_time, call_count) across calls, which is the primary reason to use a class-based decorator instead of a function-based one. The __get__ method ensures it works on instance methods by binding the instance through functools.partial.

Handling @classmethod and @staticmethod

When your class-based decorator needs to coexist with @classmethod or @staticmethod, decorator stacking order matters. The outermost decorator is applied last, so it wraps whatever the inner decorator produced.

class Example:
    # Correct: @Timer wraps the raw function, @classmethod wraps the Timer
    @classmethod
    @Timer
    def class_factory(cls, value):
        return cls(value)

    # Correct: @Timer wraps the raw function, @staticmethod wraps the Timer
    @staticmethod
    @Timer
    def utility(x, y):
        return x + y

    def __init__(self, value):
        self.value = value

# Both work correctly:
obj = Example.class_factory(42)
print(obj.value)  # 42

result = Example.utility(3, 4)
print(result)  # 7
Note

The stacking reads bottom-up: @Timer is applied first (wrapping the raw function), then @classmethod or @staticmethod wraps the Timer instance. Because @classmethod and @staticmethod implement their own __get__, they handle the binding correctly even though the wrapped object is a Timer instance rather than a plain function. If you reverse the order, placing @Timer outside @classmethod, the Timer wraps a classmethod descriptor object, which is not callable, and the decorator breaks.

Real-World Example: Call Counter With Statistics

Here is a production-ready class-based decorator that tracks call frequency and timing statistics, works on all method types, and exposes its data for monitoring:

import functools
import time
import statistics as stats

class Monitor:
    """Track call count, timing, and error rate for a function or method."""

    _registry = {}

    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)
        self.timings = []
        self.error_count = 0
        Monitor._registry[func.__qualname__] = self

    def __call__(self, *args, **kwargs):
        start = time.perf_counter()
        try:
            result = self.func(*args, **kwargs)
            self.timings.append(time.perf_counter() - start)
            return result
        except Exception:
            self.error_count += 1
            self.timings.append(time.perf_counter() - start)
            raise

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return functools.partial(self.__call__, obj)

    @property
    def call_count(self):
        return len(self.timings)

    def report(self):
        if not self.timings:
            return f"{self.func.__qualname__}: no calls"
        lines = [
            f"{self.func.__qualname__} over {self.call_count} calls:",
            f"  mean    = {stats.mean(self.timings):.6f}s",
            f"  median  = {stats.median(self.timings):.6f}s",
            f"  min     = {min(self.timings):.6f}s",
            f"  max     = {max(self.timings):.6f}s",
            f"  errors  = {self.error_count}",
        ]
        if len(self.timings) > 1:
            lines.append(f"  stdev   = {stats.stdev(self.timings):.6f}s")
        return "\n".join(lines)

    @classmethod
    def report_all(cls):
        return "\n\n".join(m.report() for m in cls._registry.values())

class OrderService:
    def __init__(self, db):
        self.db = db

    @Monitor
    def create_order(self, items):
        time.sleep(0.01)  # simulated DB write
        return {"id": 1, "items": items}

    @Monitor
    def get_order(self, order_id):
        time.sleep(0.005)  # simulated DB read
        return {"id": order_id}

service = OrderService("postgres://localhost/shop")

for _ in range(10):
    service.create_order(["widget", "gadget"])

for i in range(20):
    service.get_order(i)

print(Monitor.report_all())

The Monitor class maintains a global registry of all monitored functions and methods. Each instance tracks its own timings and error count. The __get__ method ensures it works on instance methods. The report_all classmethod provides a single entry point for dumping all monitoring data. This kind of persistent state across invocations is the scenario where class-based decorators offer a clear advantage over function-based ones.

Key Takeaways

  1. Function-based decorators handle self automatically because they return functions, and functions are descriptors. Python's default function.__get__ binds the instance as self during attribute lookup. No extra work is needed.
  2. Class-based decorators replace the method with an object that is not a function and not a descriptor. Without __get__, Python returns the decorator instance directly, and no self binding occurs. The original method never receives the instance.
  3. Implementing __get__ on the decorator class restores method binding. When obj is not None, return functools.partial(self.__call__, obj) or types.MethodType(self, obj) to pre-fill the instance as the first argument.
  4. When __get__ receives obj=None, the access is through the class, not an instance. Return self unchanged in that case, which preserves the ability to call the method on the class directly (for unbound usage).
  5. When stacking with @classmethod or @staticmethod, place your decorator inside (below) the built-in decorator. The built-in descriptor should be the outermost wrapper so its __get__ handles the binding protocol for the specific method type.

The descriptor protocol is the mechanism Python uses to turn attributes into bound methods, properties, class methods, and static methods. Understanding that a class-based decorator is just an attribute in a class's dictionary -- and that it needs __get__ to participate in binding -- is what separates class-based decorators that work everywhere from those that fail silently on instance methods.