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
| Need | Solution |
|---|---|
| Validation or computation on one attribute | @property |
| Same logic across multiple attributes or classes | Custom descriptor |
| Read-only attribute | @property without setter |
| ORM, frameworks, metaprogramming | Custom descriptor |
The natural progression: start with @property. Once the logic repeats, extract a descriptor.
