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.
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__ |
c.__dict__["d"] = "from instance dict"
c.d # "from descriptor" — data wins
c.n # "from instance dict" — non-data losesThat 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:
- Data descriptor on
type(obj)(or its MRO) obj.__dict__["x"]- Non-data descriptor, or plain class attribute
__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:
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 itThe fix is to key storage off the instance, using the name __set_name__
handed you:
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 selfguard is not optional. Without it,A.xraises instead of returning the descriptor, which breakshelp(),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:
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
class C:
def method(self): ...
C.__dict__["method"].__get__ # functions have itFunctions 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
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.
Related
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 behindproperty,classmethod,staticmethodand 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@propertycan’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,
@propertyis 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 = valueinside__set__makes all instances share a value. It’s the first bug everyone writes. - “How does
cached_propertyavoid 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__.