Accessing r.width and writing r.width = 15 with the same syntax as a plain attribute, while running validation or computation under the hood: that is what @property provides. And when that logic needs to be shared across multiple classes, descriptors come into play.

@property: getters and setters without friction

@property lets you expose a computed or validated attribute with the same syntax as a regular one.

class Rectangle:
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height

    @property
    def width(self) -> float:
        return self._width

    @width.setter
    def width(self, value: float) -> None:
        if value <= 0:
            raise ValueError("Width must be positive.")
        self._width = value

    @property
    def area(self) -> float:
        return self._width * self._height

The concrete benefit: the public API does not change. Adding validation to an existing attribute breaks no calling code. @property without a setter creates a read-only attribute. @name.deleter handles deletion via del.

@property is a descriptor

What Python does under the hood with @property is create an object of type property that implements the descriptor protocol. These two forms are strictly equivalent:

@property
def width(self):
    return self._width

# exact equivalent:
def width(self):
    return self._width
width = property(width)

A descriptor is any object that defines __get__, __set__, or __delete__, and is assigned as a class attribute (not an instance attribute). The property class implements all three methods: it is a full descriptor.

When @property is not enough

@property works well for one attribute in one class. When several classes need the same validation logic, a custom descriptor is the right tool. Duplicating properties is not the answer.

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name, None)

    def __set__(self, instance, value: float) -> None:
        if value <= 0:
            raise ValueError(f"{self.public_name} must be a positive number.")
        setattr(instance, self.private_name, value)


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

    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height


class Cylinder:
    radius = PositiveNumber()
    height = PositiveNumber()

    def __init__(self, radius: float, height: float):
        self.radius = radius
        self.height = height

__set_name__ is called automatically at class creation time (Python 3.6+). It gives the descriptor access to its own name, avoiding hardcoded strings.

The instance is None check in __get__ is essential: it handles class-level access (Rectangle.width) by returning the descriptor itself rather than attempting a getattr(None, ...).

Data descriptor vs non-data descriptor

A descriptor that defines __set__ or __delete__ is a data descriptor. It takes priority over the instance __dict__, which guarantees that validation cannot be bypassed by direct assignment on the instance.

A descriptor that only implements __get__ is a non-data descriptor and can be overridden. Python functions work this way: they become bound methods through this mechanism.

When to use which

NeedSolution
Validation or computation on one attribute@property
Same logic across multiple attributes or classesCustom descriptor
Read-only attribute@property without setter
ORM, frameworks, metaprogrammingCustom descriptor

The natural progression: start with @property. Once the logic repeats, extract a descriptor.