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. These Python tutorials cover this problem from the ground up, show why function-based decorators do not have this issue, and walk 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.

Function-Based YES
Class-Based YES
Function-Based YES — wrapper is a function; functions are descriptors
Class-Based NO — decorator instance is not a descriptor by default
Function-Based None
Class-Based Implement __get__
Function-Based NO — closures only
Class-Based YES — instance attributes
Function-Based Extra nesting — outer function returns decorator
Class-Based Extra nesting__init__ takes args, returns callable decorator
Function-Based YES
Class-Based NO
Function-Based YES
Class-Based Only with types.MethodType
Function-Based YES — no shared mutable state
Class-Based NO — class-level state needs locking
Function-Based YES (top-level functions)
Class-Based Only if __reduce__ implemented

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.

pythonimport 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. More precisely, according to Raymond Hettinger's official Python Descriptor Guide, functions are non-data descriptors that return bound methods during dotted lookup from an instance. A non-data descriptor defines __get__ but not __set__ or __delete__. This distinction matters: because functions lack __set__, an instance dictionary entry with the same name as a method takes precedence over the method during attribute lookup. That is how instances can shadow methods — and it is the same mechanism a class-based decorator without __get__ inadvertently exploits in reverse, bypassing binding entirely.

Python Pop Quiz
Why does a function-based decorator handle self on instance methods automatically, without any extra code?

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. Specifically, it is object.__getattribute__ that checks whether an attribute found in the class dictionary is a descriptor and, if so, calls its __get__. When no __get__ is present, object.__getattribute__ returns the object as-is with no transformation.

pythonimport functools

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. 1Python looks up "hello" in g.__dict__. It is not there.
  2. 2Python looks up "hello" in Greeter.__dict__. It finds a LogCalls instance.
  3. 3Python checks whether the LogCalls instance is a descriptor (has __get__). It does not.
  4. 4Python returns the LogCalls instance directly. No binding occurs.
  5. 5The caller invokes it with (). LogCalls.__call__ fires with args=() and kwargs={}.
  6. 6self.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.

Python Pop Quiz
A class-based decorator wraps a method but __get__ is not defined on the decorator class. When you call g.hello(), at exactly which step does the failure occur?

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.

pythonimport 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.

Data vs Non-Data Descriptor: What Adding __get__ Means

By implementing only __get__ on the decorator class, you create a non-data descriptor — the same classification as a plain Python function. According to the Python data model documentation, Python methods (including those decorated with @staticmethod and @classmethod) are implemented as non-data descriptors. A class-based decorator with only __get__ participates in the same lookup chain as ordinary methods. If you also defined __set__, it would become a data descriptor, which takes precedence over instance __dict__ entries — making it impossible for an instance to shadow the decorated attribute. For a decorator, this is almost never what you want, so do not add __set__ unless you have an explicit reason.

Python Pop Quiz
You need your class-based decorator's __get__ to return a bound callable. A colleague says to always use types.MethodType because it is "more correct." When is functools.partial actually the better choice?

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:

pythonimport functools
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.

functools.partial

Simpler. Creates a partial object with obj pre-filled. Works everywhere. Does not pass inspect.ismethod() — the result is a functools.partial, not a bound method. Best choice for most decorators.

types.MethodType

Creates a true bound method object. Passes inspect.ismethod() checks. Necessary when downstream code (frameworks, serializers, debuggers) inspects whether the callable is a method. Marginally more overhead.

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:

pythonimport functools
import random
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),
        }

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.

pythonclass 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.

Python Pop Quiz
You want to apply your custom @Timer decorator to a classmethod. Which of the following is correct?

Bonus: Using __set_name__ for Richer Introspection

Python 3.6 introduced __set_name__ (via PEP 487), which is called on a descriptor when the class it belongs to is created. It receives the owner class and the name the descriptor was assigned to. For a class-based decorator used as a method decorator, this means you can capture the attribute name at class creation time rather than relying solely on func.__name__.

pythonimport functools

class LogCalls:
    def __init__(self, func):
        self.func = func
        self.owner_class = None   # filled by __set_name__
        self.attr_name = None     # filled by __set_name__
        functools.update_wrapper(self, func)

    def __set_name__(self, owner, name):
        # Called once when the class body is executed
        self.owner_class = owner
        self.attr_name = name

    def __call__(self, *args, **kwargs):
        owner = self.owner_class.__name__ if self.owner_class else "?"
        name = self.attr_name or self.func.__name__
        print(f"Calling {owner}.{name}")
        return self.func(*args, **kwargs)

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

class Service:
    @LogCalls
    def process(self):
        return "processed"

s = Service()
s.process()
# Calling Service.process

__set_name__ is called once, at class definition time, not per invocation. This makes it useful for building decorators that integrate with class hierarchies, produce better error messages, or generate derived attribute names without any runtime overhead.

Real-World Example: Call Counter With Statistics

What this example covers
Global decorator registry via class variable
Per-method timing and error tracking
__get__ binding for instance methods
Statistics via the statistics module
report_all() classmethod for monitoring
Exception tracking without swallowing

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:

pythonimport 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.

Thread Safety Warning

The Monitor and Timer examples above use shared mutable state — self.timings, self.total_time, Monitor._registry. If two threads call the same decorated method simultaneously, they will race on list append and float addition. For thread-safe production use, protect writes with a threading.Lock: acquire it around self.timings.append(...) in __call__. Alternatively, use threading.local() if you want per-thread statistics rather than shared totals.

Parameterized Class-Based Decorators

Every example so far uses a decorator with no arguments: @LogCalls. A parameterized decorator — one written as @LogCalls(prefix="[SVC]") — requires a different structure. The decorator class itself can no longer receive the function in __init__, because __init__ receives the arguments instead. There are two clean patterns.

Pattern 1 — the class is the factory, returns a different callable:

pythonimport functools

class LogCalls:
    """Parameterized class-based decorator.

    Usage:
        @LogCalls(prefix="[SVC]")
        def my_method(self): ...
    """
    def __init__(self, prefix=""):
        # __init__ receives decorator arguments, NOT the function
        self.prefix = prefix

    def __call__(self, func):
        # __call__ receives the function and returns the wrapper.
        # Returning a plain function means Python's built-in function.__get__
        # handles instance binding automatically — no custom __get__ needed.
        prefix = self.prefix
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"{prefix} Calling {func.__name__}")
            return func(*args, **kwargs)
        return wrapper

class Service:
    @LogCalls(prefix="[SVC]")
    def process(self, data):
        return f"processed: {data}"

s = Service()
print(s.process("hello"))
# [SVC] Calling process
# processed: hello

Pattern 2 — keep the class as the wrapper, use a classmethod factory:

pythonimport functools

class LogCalls:
    def __init__(self, func, prefix=""):
        self.func = func
        self.prefix = prefix
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print(f"{self.prefix} Calling {self.func.__name__}")
        return self.func(*args, **kwargs)

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

    @classmethod
    def with_args(cls, prefix=""):
        """Factory for parameterized usage: @LogCalls.with_args(prefix='[X]')"""
        def decorator(func):
            return cls(func, prefix=prefix)
        return decorator

# No-argument usage (unchanged):
@LogCalls
def standalone():
    return "ok"

# Parameterized usage:
class Service:
    @LogCalls.with_args(prefix="[SVC]")
    def process(self, data):
        return f"processed: {data}"

s = Service()
s.process("hello")
# [SVC] Calling process

Pattern 2 is preferred when the decorator's core logic should live in __call__ and the class-based structure already exists. Pattern 1 is simpler when the parameterized version is the primary API and no-argument usage is secondary.

Python Pop Quiz
You write @LogCalls(prefix="[API]") above a method. Which method on the LogCalls class receives the decorated function as its argument?

Introspection, __repr__, and functools.update_wrapper

There is a subtle difference between functools.update_wrapper(self, func) (used in __init__) and @functools.wraps(func) (used inside function-based decorators). They do the same thing — copy __module__, __name__, __qualname__, __annotations__, __doc__, and update __dict__ — but @wraps is syntactic sugar that calls update_wrapper on the function being defined. Since a class-based decorator does not define a wrapper function, update_wrapper(self, func) is called directly in __init__.

What update_wrapper does not fix is __repr__. After decoration, the decorator instance's repr will read something like <__main__.LogCalls object at 0x...> rather than showing the function name. If your tooling, logging, or test output depends on reading repr(decorated_method), implement __repr__ explicitly:

pythonclass LogCalls:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __repr__(self):
        return f""

    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
        return functools.partial(self.__call__, obj)

class Greeter:
    @LogCalls
    def hello(self): ...

print(repr(Greeter.hello))
# 

Also note that update_wrapper sets a __wrapped__ attribute on the decorator instance pointing to the original function. This allows tools like inspect.unwrap() to peel back decoration layers and reach the original function — useful in testing and introspection scenarios.

Performance Considerations

Performance Note

Every time a decorated instance method is accessed via dotted lookup (e.g. obj.method), Python calls __get__, which creates a new functools.partial or types.MethodType object. This allocation happens on every access, not just on calls. In hot paths — tight loops calling obj.method() thousands of times per second — this overhead is measurable. If profiling shows the decorator as a bottleneck, cache the bound callable in the instance dictionary instead of recreating it each time:

pythonimport functools

class LogCalls:
    """Caching __get__ — creates the partial once per instance, not per access."""

    def __init__(self, func):
        self.func = func
        self.attr_name = None
        functools.update_wrapper(self, func)

    def __set_name__(self, owner, name):
        self.attr_name = name

    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
        # Cache the bound partial in the instance dict under the method name.
        # Next access finds it in obj.__dict__ directly (instance dict wins
        # over non-data descriptors), so __get__ is never called again.
        bound = functools.partial(self.__call__, obj)
        functools.update_wrapper(bound, self.func)
        if self.attr_name:
            obj.__dict__[self.attr_name] = bound
        return bound

class Greeter:
    @LogCalls
    def hello(self):
        return "hello"

g = Greeter()
g.hello()   # __get__ called, partial cached in g.__dict__['hello']
g.hello()   # reads directly from g.__dict__ — __get__ NOT called again
# Calling hello
# Calling hello

The caching pattern works because a class-based decorator with only __get__ is a non-data descriptor. Non-data descriptors yield to instance dictionary entries — so once obj.__dict__['hello'] is populated, subsequent lookups return the cached partial without invoking __get__ at all. Do not use this pattern if the decorator needs to regenerate the bound callable on each access (e.g. if it reads dynamic per-instance state).

References and Sources

The technical claims in this article are grounded in the following official Python documentation sources. All code behavior was verified against CPython.

  • Python Descriptor Guide (Raymond Hettinger, Python Software Foundation) — The authoritative reference on the descriptor protocol, how functions become bound methods, and the pure-Python equivalents of classmethod, staticmethod, property, and functools.cached_property. Available at docs.python.org/3/howto/descriptor.html.
  • Python Data Model (Python Software Foundation) — Documents the distinction between data descriptors (__get__ + __set__) and non-data descriptors (__get__ only), and establishes that Python methods — including those decorated with @staticmethod and @classmethod — are classified as non-data descriptors. Available at docs.python.org/3/reference/datamodel.html.
  • functools — Higher-order functions (Python Software Foundation) — Documents functools.update_wrapper, functools.wraps, and functools.partial, including default WRAPPER_ASSIGNMENTS (__module__, __name__, __qualname__, __annotations__, __type_params__, __doc__) and the __wrapped__ attribute that update_wrapper automatically adds. Available at docs.python.org/3/library/functools.html.
  • PEP 487 — Simpler customisation of class creation (Martin Teichmann, 2016) — Introduced __set_name__ in Python 3.6, which allows descriptors to receive the attribute name and owner class automatically at class creation time. Available at peps.python.org/pep-0487.
  • types — Dynamic type creation and names for built-in types (Python Software Foundation) — Documents types.MethodType, which creates a bound method object by associating a callable with an instance. Available at docs.python.org/3/library/types.html.

Frequently Asked Questions

Why does a class-based decorator cause a TypeError on instance methods?
A class-based decorator replaces the method with an instance of the decorator class. Because that instance does not define __get__, it is not a descriptor. When Python looks up the attribute on an instance, it returns the decorator object directly with no binding, so the original method is called without the instance as the first argument and raises TypeError: missing 1 required positional argument: 'self'.
Why do function-based decorators handle self automatically?
Function-based decorators return plain functions. Python functions implement __get__ as non-data descriptors. When a function is accessed through a class instance, function.__get__ is called automatically and returns a bound method with the instance pre-filled as the first argument. This binding step is what passes self to the wrapped function.
How do you fix a class-based decorator so it works on instance methods?
Implement __get__ on the decorator class. When obj is not None (accessed through an instance), return functools.partial(self.__call__, obj) or types.MethodType(self, obj) to bind the instance as the first argument. When obj is None (accessed through the class), return self unchanged.
What is the difference between functools.partial and types.MethodType in __get__?
Both produce a callable that binds the instance as the first argument. functools.partial creates a partial object, which is simpler. types.MethodType creates a proper bound method object that passes inspect.ismethod() checks. Use functools.partial for simplicity; use types.MethodType when downstream code expects a bound method object or uses inspect to verify method type.
What is the correct stacking order when combining a class-based decorator with @classmethod or @staticmethod?
Place your class-based decorator below (inside) @classmethod or @staticmethod in the source. Decorators are applied bottom-up, so your decorator wraps the raw function first, then @classmethod or @staticmethod wraps the decorator instance. Reversing the order causes the decorator to receive an unresolved descriptor object rather than the raw function.
What is a non-data descriptor in Python?
A non-data descriptor is an object that defines __get__ but not __set__ or __delete__. Python functions are non-data descriptors. Because they do not define __set__, instance dictionary entries take precedence over them during attribute lookup, which means instances can shadow methods by setting an attribute of the same name.
Are class-based decorators thread-safe?
Not by default. Class-based decorators that store mutable state — call counts, timing lists, registries — will have race conditions when decorated methods are called from multiple threads simultaneously. Protect writes with threading.Lock inside __call__, or use threading.local() if you want per-thread statistics rather than shared totals.

How to Fix a Class-Based Decorator on Instance Methods

  1. 1Identify the problem. Apply the decorator to an instance method and call it through an instance. If you see TypeError: missing 1 required positional argument: 'self', the decorator class does not implement __get__.
  2. 2Add __get__ to the decorator class. The method signature is def __get__(self, obj, objtype=None):. This makes the decorator a non-data descriptor, participating in Python's attribute lookup chain.
  3. 3Handle the obj is None case. When the decorated method is accessed through the class rather than an instance, obj is None. Return self unchanged so the decorator still works in unbound contexts.
  4. 4Bind the instance when obj is not None. Return functools.partial(self.__call__, obj) to pre-fill the instance as the first argument. Alternatively, return types.MethodType(self, obj) if downstream code checks inspect.ismethod().
  5. 5Verify on standalone functions. Call the decorated standalone function and confirm it still works correctly. The obj is None branch is not triggered for standalone functions because they are not looked up through an instance.
  6. 6Test with @classmethod and @staticmethod if needed. Place your decorator below (inside) the built-in decorator in the source. Confirm the stacking order is correct by calling both the class method and the static method after applying your decorator.

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.
  6. Adding only __get__ makes the decorator a non-data descriptor, matching ordinary method behavior. Non-data descriptors yield to instance dictionary entries during lookup, so instances can still shadow decorated methods. Adding __set__ would promote the decorator to a data descriptor that takes priority over instance __dict__ — almost never the correct behavior for a decorator.
  7. Python 3.6+ class-based decorators can implement __set_name__ to capture the owner class and attribute name at class creation time. This is called once when the class body executes, not per invocation, and enables richer introspection and error messages with no runtime cost.
  8. Parameterized class-based decorators require a structural shift. When using @LogCalls(prefix="x"), __init__ receives the arguments, not the function. The function arrives via __call__ or a factory classmethod. Keep the pattern explicit so readers of the code can distinguish argument-receiving from function-receiving phases.
  9. functools.update_wrapper copies metadata but does not fix __repr__. Implement __repr__ on the decorator class if tooling, logging, or tests read repr() of decorated methods. The __wrapped__ attribute added by update_wrapper lets inspect.unwrap() reach the original function through any number of decoration layers.
  10. Class-based decorators with __get__ create a new partial or bound method on every attribute access, not just on calls. In hot paths, store the bound callable in obj.__dict__ inside __get__ so subsequent lookups bypass the descriptor entirely. This works because non-data descriptors yield to instance dictionary entries.
  11. Class-level mutable state requires explicit thread synchronization. Lists, counters, and registries shared across method calls are not thread-safe by default. Use threading.Lock around writes, or threading.local() for per-thread isolation.

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. The distinction between data and non-data descriptors, the availability of __set_name__ in Python 3.6+, and the precise role of object.__getattribute__ in triggering descriptor lookup are the surrounding details that give a complete picture of how binding actually works at the language level.