Properties and descriptors

Python · Advanced · 14 min

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__. property is 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_property relies 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 > 0

Remember

  • Start with plain attributes; switch to @property when 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.

Check yourself

  1. Why does assigning `obj.method = 1` shadow a method, but `obj.prop = 1` on a setter-less property raises AttributeError?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/howto/descriptor.html