What are descriptors, and how does `@property` relate to them?

7 minadvancedoopdescriptorsadvanced

Quick Answer

A descriptor is any object whose class defines __get__, __set__, and/or __delete__, and is stored as a class attribute. When accessed through an instance, Python calls the descriptor's methods instead of returning it directly. property is the most common built-in descriptor. You can write your own reusable descriptors to share validation/behavior across many attributes or classes, which a single @property per attribute can't do.

Detailed Answer

The descriptor protocol

A class is a descriptor if it implements any of:

class Descriptor:
    def __get__(self, obj, objtype=None): ...
    def __set__(self, obj, value): ...
    def __delete__(self, obj): ...

When such an object is assigned as a class attribute of another class, accessing that attribute through an instance routes through the descriptor's methods instead of Python's normal attribute lookup.

A reusable validating descriptor

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

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

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self._name[1:]} must be positive")
        setattr(obj, self._name, value)

class Circle:
    radius = PositiveNumber()     # reusable across classes/attributes

class Rectangle:
    width = PositiveNumber()
    height = PositiveNumber()

Unlike @property, which you write once per attribute, PositiveNumber is written once and reused for radius, width, and height. This is exactly how libraries like Django models and attrs/dataclasses validators implement field-level behavior under the hood.

property is a descriptor

class property:
    def __get__(self, obj, objtype=None): ...
    def __set__(self, obj, value): ...

@property is a convenience wrapper that constructs exactly this kind of descriptor object from the getter/setter/deleter functions you decorate. It's the special case of "one descriptor instance per attribute, defined inline," while a hand-written descriptor class is the general case of "one reusable descriptor type, instantiated per attribute."

Data descriptors vs non-data descriptors

  • Data descriptor: defines __set__ (and/or __delete__) — takes priority over instance __dict__ even if the instance has its own entry with the same name.
  • Non-data descriptor: defines only __get__ — instance __dict__ takes priority over it. This is how plain functions work as methods: a function is a non-data descriptor whose __get__ produces a bound method, but you could shadow it by assigning directly into instance.__dict__["method_name"].

Descriptors are the general mechanism behind attribute access customization — property, bound methods, and classmethod/staticmethod are all built on the descriptor protocol. Write a custom descriptor when you need the same validation/behavior reused across multiple attributes or classes. Use @property for a one-off attribute on a single class.