While the @property decorator provides an elegant, high-level interface for creating managed attributes, its implementation rests squarely on the more fundamental concept of descriptors. Understanding this machinery is crucial for any Python developer who wishes to move beyond merely using properties to extending and customizing the behavior of object attribute access.

The Descriptor Protocol: A Primer

At its core, a descriptor is any object that defines at least one of the methods in the descriptor protocol: __get__(), __set__(), or __delete__(). These methods are hooks that are automatically called by Python’s internal attribute access machinery when a descriptor is accessed as an attribute on another object. The __get__ method is called when the attribute is retrieved (instance.descriptor), __set__ when it’s assigned (instance.descriptor = value), and __delete__ when it’s deleted (del instance.descriptor). A descriptor that only defines __get__ is termed a non-data descriptor, while one that defines __set__ or __delete__ is a data descriptor. This distinction is critical, as data descriptors have precedence over an instance’s dictionary, which is why properties can override instance attributes.

property: A Built-in Data Descriptor

The property class is not magic; it is a sophisticated, built-in data descriptor. When you decorate a method with @property, you are essentially replacing that method name in the class namespace with an instance of the property descriptor. This descriptor holds references to the function you defined as the “getter.” Subsequent decorators like @<name>.setter and @<name>.deleter are not themselves decorators in the traditional sense but rather special methods that update the same property object with references to the setter and deleter functions.

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):
        """Get the current temperature in Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible.")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Get the temperature in Fahrenheit. This is a computed, read-only property."""
        return (self.celsius * 9 / 5) + 32

# Under the hood, the class definition is roughly equivalent to:
# def get_celsius(self): ...
# def set_celsius(self, value): ...
# celsius = property(fget=get_celsius, fset=set_celsius)
# fahrenheit = property(fget=get_fahrenheit)  # No fset, so it's read-only

# Usage
temp = Temperature(25)
print(temp.celsius)    # Calls Temperature.celsius.__get__(temp, Temperature)
temp.celsius = 30      # Calls Temperature.celsius.__set__(temp, 30)
print(temp.fahrenheit) # 86.0
# temp.fahrenheit = 100 # AttributeError: can't set attribute (no __set__ defined)

How Attribute Access Resolves to Descriptor Methods

When you write obj.attr, Python follows a specific lookup chain defined by the Descriptor HowTo Guide. For obj.attr:

  1. Python first checks if 'attr' is a data descriptor on the class (type(obj)) or any of its parent classes. If it is, it calls attr.__get__(obj, type(obj)) and returns that value. This is why a property (a data descriptor) overrides an instance attribute with the same name.
  2. If not, it checks the object’s instance dictionary obj.__dict__ for the key 'attr'.
  3. If not found there, it checks if 'attr' is a non-data descriptor on the class. If it is, it calls its __get__ method.
  4. Finally, it checks the class’s __dict__ for the attribute, and if that fails, proceeds through the MRO.

Assignment (obj.attr = value) and deletion (del obj.attr) follow a similar but simpler path: they look for a data descriptor first. If one exists with a __set__ or __delete__ method, that method is called. If no data descriptor is found, the value is simply stored in or deleted from the object’s instance dictionary obj.__dict__.

Common Pitfalls and Best Practices

A common pitfall arises from the precedence of data descriptors. If you create a property named x and then try to set an instance attribute x directly on an instance, the property’s __set__ method will always be called first. You cannot “hide” a property by setting an instance attribute with the same name. The workaround is to store the value under a different, often private name (e.g., _x), which is the standard practice.

Another subtle issue involves class-level access. Accessing a property on a class (Class.prop) does not invoke __get__ because the purpose of __get__ is to bind the descriptor to an instance. Instead, you get the descriptor object itself. This is why you must pass an instance to the property.getter if you call it directly: Temperature.celsius.__get__(temp).

Best practices dictate that you should use properties to maintain a consistent interface. If you start with a simple public attribute and later need to add validation or computation, you can change it to a property without breaking existing code, as the syntax for access remains identical (obj.attr). This encapsulates the internal implementation and is a cornerstone of good object-oriented design in Python.