Backend / Python OOP / 08_descriptors_in_python.md

Descriptors

Updated 6 interview angles 4 min read source
On this page7
  1. Data vs non-data, and why it matters
  2. The bug everyone writes first
  3. A worked example: validation
  4. What is already a descriptor
  5. When to reach for one
  6. Related
  7. Interview angle

Descriptors

An object that defines __get__, __set__ or __delete__ and is assigned as a class attribute. Python then routes attribute access on instances through it. This is the mechanism behind property, classmethod, staticmethod, bound methods and every ORM field you have used.

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

obj is the instance (or None when accessed on the class), objtype the class. __set_name__ runs once at class creation and tells the descriptor which attribute name it was bound to.

Data vs non-data, and why it matters

The single distinction everything else follows from:

Defines Precedence
Data __set__ or __delete__ beats instance __dict__
Non-data only __get__ loses to instance __dict__
python
c.__dict__["d"] = "from instance dict"
c.d        # "from descriptor"      — data wins
c.n        # "from instance dict"   — non-data loses

That is precisely why you cannot shadow a @property by assigning to the instance — property defines __set__, so it is a data descriptor — while functools.cached_property can cache into the instance __dict__, because it deliberately defines only __get__.

The full lookup order

For obj.x, Python walks:

  1. Data descriptor on type(obj) (or its MRO)
  2. obj.__dict__["x"]
  3. Non-data descriptor, or plain class attribute
  4. __getattr__, if defined

Most surprising attribute behaviour in Python is explained by this list.

The bug everyone writes first

State belongs on the instance, not on the descriptor. A descriptor is one object shared by every instance of the class:

python
class Broken:
    def __get__(self, obj, objtype=None):
        return self.value
    def __set__(self, obj, value):
        self.value = value      # on the descriptor!

a1, a2 = A(), A()
a1.x = 1
a2.x = 2
a1.x        # 2 — a2 overwrote it

The fix is to key storage off the instance, using the name __set_name__ handed you:

python
class Field:
    def __set_name__(self, owner, name):
        self.attr = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self          # accessed on the class
        return getattr(obj, self.attr)

    def __set__(self, obj, value):
        setattr(obj, self.attr, value)

Gotcha: the if obj is None: return self guard is not optional. Without it, A.x raises instead of returning the descriptor, which breaks help(), inspect, and anything introspecting the class.

A worked example: validation

The case that justifies a descriptor over @property — the behaviour is reused across several fields:

python
class Positive:
    def __set_name__(self, owner, name):
        self.attr = "_" + name

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

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.attr} must be > 0")
        setattr(obj, self.attr, value)


class Product:
    price = Positive()
    stock = Positive()

Two fields, one validation rule, no repetition. With @property this is the same nine lines written twice.

What is already a descriptor

python
class C:
    def method(self): ...

C.__dict__["method"].__get__       # functions have it

Functions are non-data descriptors — that is how obj.method produces a bound method: __get__ returns a partial with self applied. classmethod, staticmethod and property are the same machinery, and __slots__ creates one data descriptor per slot.

Knowing this reframes the question “how does self get passed?” from magic to a protocol you could implement yourself.

cached_property and the precedence trick

python
from functools import cached_property

class Report:
    @cached_property
    def rows(self):
        return expensive_query()

First access computes and writes rows into the instance __dict__. Every later access finds it at step 2 of the lookup and never reaches the descriptor. It works only because cached_property is non-data — which is also why you cannot use it on a class with __slots__.

When to reach for one

Situation Use
One computed attribute @property
Same rule on many fields a descriptor
Expensive, cached cached_property
Whole-object validation Pydantic

For a single attribute a descriptor is over-engineering. The moment you are writing the third near-identical @property, it stops being.

Interview angle 6

  • “What is a descriptor?” — an object defining __get__, __set__ or __delete__ that, when assigned as a class attribute, intercepts attribute access on instances. It’s the mechanism behind property, classmethod, staticmethod and ORM fields.
  • “Data versus non-data descriptor?” — a data descriptor defines __set__ or __delete__ and takes precedence over the instance __dict__; a non-data descriptor defines only __get__ and is shadowed by an instance attribute. That precedence is exactly why @property can’t be overwritten on an instance while a cached method can.
  • “What’s the lookup order?” — type-level data descriptor, then instance __dict__, then type-level non-data descriptor, then __getattr__. Knowing this explains most surprising attribute behaviour.
  • “When would you write one?” — reusable attribute behaviour across many fields: validation, type coercion, lazy loading, unit conversion. For a single attribute, @property is simpler. Use __set_name__ so the descriptor learns its own attribute name automatically.
  • “Where does the state go?” — on the instance, never on the descriptor. The descriptor is one object shared by every instance, so self.value = value inside __set__ makes all instances share a value. It’s the first bug everyone writes.
  • “How does cached_property avoid recomputing?” — it is deliberately non-data, so after the first call it writes the value into the instance __dict__, which then wins the lookup and the descriptor is never consulted again. That also means it cannot work with __slots__.