Contents
  1. What a Descriptor Is
  2. Data Descriptors and Non-Data Descriptors
  3. How @property Works
  4. How @classmethod Works
  5. How @staticmethod Works
  6. Writing a Custom Descriptor
  7. What You Can Do Now
← All posts

The Machinery Behind Python's Built-in Decorators: Descriptors Explained

Python's @property, @classmethod, and @staticmethod are not special compiler magic — they are ordinary objects that implement the descriptor protocol. Understanding that protocol reveals how attribute access works at the interpreter level and how to write your own reusable attribute behaviour.

When you write @property above a method, Python does not activate a special language feature reserved for that decorator. It instantiates the built-in property class and assigns the result to the class body. When you later access that attribute on an instance, Python’s attribute lookup machinery detects the object sitting in the class dictionary, recognises it as a descriptor, and calls its __get__ method instead of returning the object directly. Every decorator covered here, @property, @classmethod, and @staticmethod, is implemented through this one mechanism.

What a Descriptor Is

A descriptor is any object whose class defines at least one of __get__, __set__, or __delete__. These methods have the following signatures:

object.__get__(self, instance, owner=None)
object.__set__(self, instance, value)
object.__delete__(self, instance)

The instance parameter is the object through which the descriptor was accessed, or None when accessed directly through the class. The owner parameter is always the class that owns the descriptor.

Descriptors must be assigned as class variables to function. Assigning a descriptor to an instance attribute does nothing special. The interpreter only consults the descriptor protocol when the attribute is found in the class or one of its bases via the MRO.

Data Descriptors and Non-Data Descriptors

The distinction between the two types determines priority in the attribute lookup order.

A data descriptor defines __set__ or __delete__ (or both), in addition to __get__. It takes precedence over any entry of the same name in the instance’s __dict__. This means that even if you write directly to instance.__dict__['x'], a data descriptor named x on the class will still intercept access to instance.x.

A non-data descriptor defines only __get__. It can be overridden by an entry in the instance dictionary. Regular functions are non-data descriptors, which is why you can shadow a method on a specific instance by assigning to its name.

The full priority order resolved by object.__getattribute__ is: data descriptors from the class, instance variables, non-data descriptors from the class, then other class variables.

How @property Works

property is a data descriptor. It defines __get__, __set__, and __delete__, making it immune to being shadowed by an instance attribute of the same name. The following is the pure-Python equivalent of how the built-in property class is implemented:

class Property:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self          # accessed via the class, return the descriptor itself
        if self.fget is None:
            raise AttributeError
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

When obj is None, __get__ returns the descriptor itself. This is what makes MyClass.x return the property object rather than raising an error. The .setter and .deleter convenience methods each create a new property instance with the additional callable filled in, which is why the decorated setter must be assigned back to the same name.

How @classmethod Works

classmethod is also a descriptor. When its __get__ is called, it does not bind the wrapped function to the instance. It binds it to the class, returning a bound method whose first argument will be the class rather than the instance. This holds whether access originates from an instance or from the class directly.

import functools

class ClassMethod:
    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        return MethodType(self.f, cls)

The practical consequence is that @classmethod is the standard mechanism for alternative constructors. Because cls is always the actual class the method was called on, subclasses that call the classmethod receive their own class rather than the parent’s, which allows them to construct instances of themselves.

class Record:
    def __init__(self, data):
        self.data = data

    @classmethod
    def from_string(cls, raw):
        return cls(raw.split(","))


class AuditedRecord(Record):
    pass


r = AuditedRecord.from_string("a,b,c")
print(type(r))   # <class '__main__.AuditedRecord'>

from_string constructs an AuditedRecord, not a Record, because cls received AuditedRecord.

How @staticmethod Works

staticmethod is a non-data descriptor. Its __get__ returns the wrapped function unchanged, with no binding performed at all. The function receives neither the instance nor the class as an implicit first argument.

class StaticMethod:
    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, objtype=None):
        return self.f

Because __set__ and __delete__ are absent, staticmethod is a non-data descriptor and could in principle be shadowed by an instance attribute of the same name, though doing so would be unusual in practice. The lookup result is identical whether the method is called on an instance or on the class directly.

class Converter:
    @staticmethod
    def celsius_to_kelvin(c):
        return c + 273.15

Converter.celsius_to_kelvin(100)    # 373.15
Converter().celsius_to_kelvin(100)  # 373.15

Writing a Custom Descriptor

A practical use for a custom descriptor is enforcing a constraint on an attribute across every instance of a class without repeating the validation logic inside __init__. Because descriptors live on the class, a single descriptor object handles every instance.

The __set_name__ hook, called automatically when the class body is processed, gives the descriptor access to its own attribute name. This allows it to store values on the instance under a private key without that key being hardcoded into the descriptor class.

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.public_name} must be a number, got {value!r}")
        if value <= 0:
            raise ValueError(f"{self.public_name} must be positive, got {value!r}")
        setattr(obj, self.private_name, value)


class Warrior:
    power = PositiveNumber()
    stamina = PositiveNumber()

    def __init__(self, name, power, stamina):
        self.name = name
        self.power = power       # routed through PositiveNumber.__set__
        self.stamina = stamina   # routed through PositiveNumber.__set__


w = Warrior("Goku", 9001, 500)
print(w.power)     # 9001
print(w.stamina)   # 500

w.power = -1       # ValueError: power must be positive, got -1
w.stamina = "max"  # TypeError: stamina must be a number, got 'max'

Because PositiveNumber defines __set__, it is a data descriptor. Assignment through the instance is always intercepted, regardless of whether a matching key exists in the instance’s __dict__.

What You Can Do Now

Add a RangedNumber descriptor to the Warrior class above. The descriptor should accept min_val and max_val arguments at definition time and enforce that any assigned value falls within that range.

class RangedNumber:
    def __init__(self, min_val, max_val):
        self.min_val = min_val
        self.max_val = max_val

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.public_name} must be a number")
        if not (self.min_val <= value <= self.max_val):
            raise ValueError(
                f"{self.public_name} must be between {self.min_val} and {self.max_val}, got {value!r}"
            )
        setattr(obj, self.private_name, value)


class Warrior:
    power = RangedNumber(1, 9999)
    stamina = RangedNumber(1, 100)

    def __init__(self, name, power, stamina):
        self.name = name
        self.power = power
        self.stamina = stamina


w = Warrior("Vegeta", 8500, 95)
print(w.power)       # 8500

w.power = 10000      # ValueError: power must be between 1 and 9999, got 10000
w.stamina = 0        # ValueError: stamina must be between 1 and 100, got 0

From here, extend RangedNumber to also accept an optional dtype argument that restricts the attribute to a specific type such as int or float, and verify that both the type check and the range check occur in the correct order.

← All posts