Lets you start with plain attributes and add logic later without changing the public API.
In Python you expose plain attributes first and don't write getters and setters "just in case". If you later need logic — validation, a computed value, lazy loading — turn the attribute into a property and callers don't change:
@property
def celsius(self): return self._celsius
@celsius.setter
def celsius(self, value): ...A property with only a getter is read-only. functools.cached_property computes a value on first access and stores it on the instance, ideal for expensive derived data on effectively immutable objects.
Properties are built on a more general mechanism, the descriptor protocol. A descriptor is an object stored as a class attribute that defines __get__, and optionally __set__ and __delete__. When you access obj.attr, Python finds the descriptor on the class and calls descriptor.__get__(obj, type(obj)) instead of returning it.
- Data descriptors define
__set__or__delete__; they take priority over the instance's__dict__.propertyis one. - Non-data descriptors define only
__get__; an instance attribute of the same name overrides them. Plain functions are non-data descriptors —__get__is what turns a function into a bound method.cached_propertyrelies on this too: it writes the result into the instance dict, which then shadows it.
Write your own descriptor when the same attribute logic repeats across many fields or classes, such as typed or range-checked fields. __set_name__(self, owner, name) tells the descriptor which attribute name it was assigned to, so it can store data under that name. ORMs like SQLAlchemy and Django use descriptors for model fields.
Example
class Positive: # a reusable data descriptor
def __set_name__(self, owner, name):
self.private = "_" + name
def __get__(self, obj, objtype=None):
if obj is None: return self # accessed on the class
return getattr(obj, self.private)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.private[1:]} must be > 0")
setattr(obj, self.private, value)
class Product:
price, weight = Positive(), Positive()
def __init__(self, price, weight):
self.price, self.weight = price, weight # goes through __set__
@property
def price_per_kg(self): # read-only computed attribute
return round(self.price / self.weight, 2)
p = Product(12.0, 0.5)
print(p.price_per_kg) # 24.0
try:
p.weight = -1
except ValueError as e:
print(e) # weight must be > 0Remember
- Start with plain attributes; switch to
@propertywhen logic is needed - A descriptor is a class attribute with
__get__/__set__ - Data descriptors beat the instance dict; non-data ones (like functions) don't
Watch out for
- Storing the value under the same name as the property, causing infinite recursion.