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:
- Python looks up
"hello"ing.__dict__. It is not there. - Python looks up
"hello"inGreeter.__dict__. It finds aLogCallsinstance. - Python checks whether the
LogCallsinstance is a descriptor (has__get__). It does not. - Python returns the
LogCallsinstance directly. No binding occurs. - The caller invokes it with
().LogCalls.__call__fires withargs=()andkwargs={}. self.func(*args, **kwargs)calls the originalhello()with no arguments.helloexpectsselfas 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.
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.
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
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
- Function-based decorators handle
selfautomatically because they return functions, and functions are descriptors. Python's defaultfunction.__get__binds the instance asselfduring attribute lookup. No extra work is needed. - 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 noselfbinding occurs. The original method never receives the instance. - Implementing
__get__on the decorator class restores method binding. Whenobjis notNone, returnfunctools.partial(self.__call__, obj)ortypes.MethodType(self, obj)to pre-fill the instance as the first argument. - When
__get__receivesobj=None, the access is through the class, not an instance. Returnselfunchanged in that case, which preserves the ability to call the method on the class directly (for unbound usage). - When stacking with
@classmethodor@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.