Descriptors: one precedence rule that property, methods and slots all grow out of
A descriptor is an object on the class with __get__, __set__ or __delete__. Everything else follows from a single line: a data descriptor outranks the instance dictionary, a non-data one is outranked by it. That is where a working cached_property comes from — and a failing @classmethod under someone else's decorator.
Full technical treatment
TL;DR
A descriptor is an object that lives on the CLASS and decides for itself what to return when an INSTANCE is asked for an attribute. A dotted access is not always a field read: a property runs code when you touch it, and an ordinary function on a class turns into a bound method. Both are descriptors, and the language has no separate machinery for either.
Hence the main consequence: there is exactly one criterion — the object's
type has __get__, __set__ or __delete__. property, an ordinary method,
staticmethod, classmethod, a __slots__ slot and
functools.cached_property already are descriptors, which is to say you use
them every day — and it is why obj.method = ... goes through while
obj.prop = ... raises.
Beyond that is one line of precedence everything else follows from. A
data descriptor (one with __set__ or __delete__) outranks the instance
dictionary; a non-data descriptor (only __get__) is outranked by it. From
that one line follow both why cached_property caches and why @classmethod
breaks under someone else's decorator — silently while the class is built, with
a TypeError on the first call. The protocol itself costs nothing: a slot is
17.04 ns against 17.00 for a plain attribute on 3.13.7; what is expensive is a
getter written in Python (property is ×2.06).
- a class declares methods, while an instance holds its own data;
obj.attrreads an attribute andobj.attr = …changes it;- a function declared in a class is called on an instance as a method and receives that instance as its first argument.
__get__,__set__,__delete__,__set_name__, the split into data and non-data descriptors;object.__getattribute__, method resolution order, metaclasses,__slots__,functools.cached_property.
Base: why a dot is not always a field read
The usual picture of an attribute is simple: an object has a dictionary, and
obj.attr takes the value out of it. As long as a class holds nothing but
data, that picture holds.
Two tools people use every day do not fit into it.
The first is a property. obj.price looks like reading a field, yet it
runs code: the body of a method marked @property. There may be no price
stored on the instance at all.
The second is an ordinary method. The class holds one function for the
whole class. But Class.method gives back that very function, while
instance.method gives a bound method that will pass the instance as the first
argument by itself. One object on the class, and the result of the access
depends on who was asked.
Which raises the question this whole lesson answers: who turns the object lying on the class into what the dot returns, and when?
The answer is: that object itself. The language has a convention — if an object
found on the class defines one of three special methods, __get__, __set__
or __delete__, then attribute access does not hand back the object but calls
its method and hands back the result. Such objects are called descriptors, and
the three methods are the descriptor protocol. A property and a function are
built exactly that way; nothing special was added to the language for them.
The second thing to know straight away is who wins. A value under the same
name may also sit on the instance, and then one access has two candidates: the
entry on the instance and the descriptor on the class. The instance does not
always win. A descriptor with __set__ or __delete__ (a data descriptor)
outranks the instance dictionary; a descriptor with only __get__ is outranked
by it.
That is already enough to answer the basic interview question. Everything below
is about how a descriptor learns its own name, why that one line of precedence
gives both a working cached_property cache and the @classmethod failure
under someone else's decorator, and what all of it costs.
Mechanism 1: what a descriptor is
__get__, __set__ or __delete__. That is the data model, not CPython.The whole definition fits into one sentence of the reference:
Define any of these methods and an object is considered a descriptor and can
override default behavior upon being looked up as an attribute.
"Any of these" means __get__, __set__, __delete__. The smallest possible
descriptor is three lines:
class Loud:
def __get__(self, obj, owner=None):
return "somebody asked for me"
class Thing:
attr = Loud()
print(Thing().attr) # somebody asked for meThis is the place to separate two things usually glued together.
Being a descriptor is a property of the object, not of where it sits. What
makes an object a descriptor is its TYPE: if the type implements __get__,
__set__ or __delete__, the object is a descriptor wherever it lives.
The protocol is invoked automatically under one condition only — when the
object is found by looking it up on the TYPE of whatever is being asked for an
attribute. That is already a rule of attribute lookup, not the definition of a
descriptor, and it is what explains the next example: Loud() on an instance is
still a descriptor, but nobody calls it.
t = Thing()
t.other = Loud()
print(t.other) # <__main__.Loud object at 0x...> — just an objectMechanism 2: one precedence rule
Everything after this rests on a distinction worth memorising verbatim:
If an object defines set or delete, it is
considered a data descriptor. Descriptors that only define get
are called non-data descriptors.
And immediately, why the distinction is there at all:
If an instance's dictionary has an entry with the same name as a data
descriptor, the data descriptor takes precedence. If an instance's dictionary
has an entry with the same name as a non-data descriptor, the dictionary entry
takes precedence.
It is checked by an experiment in which everything is identical except the
presence of __set__:
class Data:
def __get__(self, obj, owner=None): return "from the data descriptor"
def __set__(self, obj, value): obj.__dict__["data"] = f"intercepted: {value}"
class NonData:
def __get__(self, obj, owner=None): return "from the non-data descriptor"
class Both:
data = Data()
plain = NonData()
obj = Both()
obj.__dict__["data"] = "put straight into __dict__"
obj.__dict__["plain"] = "put straight into __dict__"
print(obj.data) # from the data descriptor <- the descriptor won
print(obj.plain) # put straight into __dict__ <- the dictionary wonThe instance dictionary holds the same thing in both cases and the result
differs. The only difference between Data and NonData is three words:
def __set__(self, obj, value).
The full lookup chain is written down in the descriptor guide as a single sentence too:
Instance lookup scans through a chain of namespaces giving data descriptors
the highest priority, followed by instance variables, then non-data
descriptors, then class variables, and lastly getattr if it is
provided.
Below, that same chain is played out step by step. The toggle changes only what sits on the class — and you can see which step the search stops at.
Mechanism 3: how a descriptor learns its own name
A descriptor is one object for the whole class, and on two attributes it would be the same object. The only way it can tell them apart is by name, and the name is handed to it when the class is created:
Automatically called at the time the owning class owner is created. The
object has been assigned to name in that class.
class Named:
def __set_name__(self, owner, name):
self.name = name
print(f"__set_name__: class {owner.__name__}, name {name!r}")
def __get__(self, obj, owner=None):
return f"I know I am called {self.name}"
class WithNames:
first = Named()
second = Named()__set_name__: class WithNames, name 'first'
__set_name__: class WithNames, name 'second'
Both lines print while class executes, not on first access. The caveat
from the same paragraph of the reference matters in practice: if a class
attribute is assigned after the class has been created, __set_name__ will not
be called automatically.
Mechanism 4: you already use them
The feeling that descriptors are rare exotica lasts exactly until one check: asking the built-ins whether they have the protocol's methods.
| what | __get__ | __set__ | __delete__ | kind |
|---|---|---|---|---|
| a function (method) | yes | no | no | non-data |
property | yes | yes | yes | data |
staticmethod | yes | no | no | non-data |
classmethod | yes | no | no | non-data |
a __slots__ slot | yes | yes | yes | data |
functools.cached_property | yes | no | no | non-data |
Identical on 3.11, 3.12, 3.13 and 3.14. About methods the reference is explicit:
Python methods (including those decorated with staticmethod and
classmethod) are implemented as non-data descriptors. Accordingly,
instances can redefine and override methods.
This, incidentally, answers a question usually asked separately: why
obj.method = something works while obj.prop = something raises. A method is
a non-data descriptor and an instance entry shadows it. property is a data
descriptor, and it intercepts the write itself.
Mechanism 5: why @classmethod breaks under someone else's decorator
This is the passage the lesson was written for: the decorators lesson says
@staticmethod and @classmethod go outermost — and the reason was named
"descriptor" without ever being explained. Here it is.
The decorator receives not a function but a staticmethod or classmethod
object — the descriptor itself. The wrapper inside the decorator calls
f(*args, **kwargs), and from there one check decides everything:
class Sample:
@staticmethod
def static(): pass
@classmethod
def klass(cls): pass
print(callable(Sample.__dict__["static"])) # True
print(callable(Sample.__dict__["klass"])) # FalseA staticmethod object has been callable since 3.10 — the documentation
records it in its own line, Changed in version 3.10: Static methods are now callable
.
classmethod has no such line, and the object stays non-callable. Verified on
3.11, 3.12, 3.13 and 3.14 — identical in all four.
The practical consequence is nastier than the asymmetry itself: the class is
defined silently. functools.wraps copies attributes through try/except
and does not trip over the descriptor, so the error appears not while the class
is being built but on the first call — TypeError: 'classmethod' object is not callable.
Mechanism 6: cached_property — the cache rests on precedence
functools.cached_property is the clearest demonstration that the precedence
rule is not an abstraction. Look at __get__:
# Lib/functools.py:1110–1134, tag v3.14.5, abridged
def __get__(self, instance, owner=None):
...
cache = instance.__dict__
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
cache[self.attrname] = val
return valThe descriptor puts the result into the instance dictionary under its own
name. And because it is a NON-data descriptor, from the next access on the
dictionary outranks it — and __get__ is never called again. The cache here is
not a trick but a direct consequence of precedence:
class Lazy:
@functools.cached_property
def heavy(self):
print("body ran")
return "computed"
lazy = Lazy()
print(lazy.__dict__) # {}
lazy.heavy # body ran
print(lazy.__dict__) # {'heavy': 'computed'}
lazy.heavy # silence: the descriptor was never askedThe same fact explains two limitations usually learned the hard way.
cached_property does not work with __slots__: there is nowhere to put the
value, there is no __dict__. And the only way to clear the cache is
del obj.attr — that is, by removing the dictionary entry, not by talking to
the descriptor.
Deeper: the whole rule — the four branches of object.__getattribute__
object.__getattribute__. The order is promised by the language; the branching is a detail.The precedence rule above is an abbreviation. The full logic of the dot is
written down in object.__getattribute__, and the documentation gives its
equivalent in Python itself:
The logic for a dotted lookup is in object.__getattribute__. Here is a pure
Python equivalent:
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)Read it as a list of four branches in order: data descriptor, instance dictionary, non-data descriptor, class variable. The precedence rule from the section above is two pairs of neighbouring branches: branch 1 beats branch 2, and branch 2 beats branch 3.
That the pseudocode really is equivalent to the dot can be checked by running it:
one class, five names, each fetched both ways
(bench/attr-lookup/getattribute_equivalent.py, identical output on 3.11–3.14;
here and below it is abridged to the relevant lines and its labels translated):
3) which branch of the pseudocode fired:
data -> branch 1 (data descriptor): the instance __dict__ was IGNORED
nondata -> branch 2 (instance variable): the instance __dict__ WON
plain -> branch 2 (instance variable)
own -> branch 2 (instance variable)
method -> branch 3 (non-data descriptor), the function is itself a descriptor: True | __set__: False
(Labels translated from the script's output.) That method line answers why an
ordinary method works at all: a function is a non-data descriptor, and the
bound method comes out of branch 3.
What is not in this code is __getattr__. The hook for a miss is nowhere in
the pseudocode, and that is not an omission:
Note, there is no __getattr__ hook in the __getattribute__ code. That is why
calling __getattribute__ directly or with super().__getattribute__ will
bypass __getattr__ entirely. Instead, it is the dot operator and the
getattr() function that are responsible for invoking __getattr__ whenever
__getattribute__ raises an AttributeError.
Checked on a class with a __getattr__: the dot calls the fallback, a direct
object.__getattribute__ does not — it raises AttributeError. That is exactly
the difference that makes a proxy written "through super().__getattribute__"
lose its own __getattr__.
And one more line of the pseudocode worth memorising on its own: objtype = type(obj). The whole of the next section follows from it.
Deeper: the same lookup one floor up — a descriptor on the metaclass
C.x and c.x are two different lookups, and they differ by exactly one line of
pseudocode. For an instance, objtype = type(obj) gives the class; for a class,
the metaclass. The documentation states it as a separate but identical
algorithm:
The logic for a dotted lookup such as A.x is in type.__getattribute__. The
steps are similar to those for object.__getattribute__ but the instance
dictionary lookup is replaced by a search through the class's method resolution
order. If a descriptor is found, it is invoked with desc.__get__(None, A).
And in the same place, on the difference between the calls:
object.__getattribute__ and type.__getattribute__ make different calls to
__get__. The first includes the instance and may include the class. The second
puts in None for the instance and always includes the class.
The practical consequence is stronger than it sounds
(bench/attr-lookup/metaclass_descriptor.py, identical output on 3.11–3.14):
2) C.on_meta -> <on-the-metaclass>
on-the-metaclass.__get__(obj=class C, objtype=Meta)
3) C().on_meta -> AttributeError: 'C' object has no attribute 'on_meta'
calls to __get__: none
C.__mro__ : ['C', 'object']
type(C).__mro__: ['Meta', 'type', 'object']
(Labels translated from the script's output.) A descriptor on the metaclass does
not "fail to fire" for an instance — for the instance it does not exist. Zero
calls to __get__ and an ordinary AttributeError, because the metaclass is not
in type(c).__mro__. This is the only way to make an attribute visible on the
class and invisible on the instance — and, at the same time, the commonest cause
of bafflement when someone tries to read it off an instance.
The precedence rule, meanwhile, is one rule for both floors:
6) a non-data descriptor on the metaclass:
after E.nd = 'overridden' -> overridden | in E.__dict__: True
the CLASS __dict__ overrides the metaclass's non-data descriptor,
exactly as the instance __dict__ overrides the class's non-data descriptor
7) the name `both` is declared on the metaclass and on the class:
F.both -> <metaclass>
F().both -> <class>
F.both BEAT F.__dict__['both']: a DATA descriptor on type(F)
overrides F's own dictionary — by the same rule
So learning the four branches once buys you the behaviour of metaclasses as well:
the same branches, with vars(instance) replaced by the class's MRO.
There is one small thing people trip over when moving a descriptor between
floors: __set_name__ is called only for descriptors sitting in the body of
their own class. For a descriptor on the metaclass, owner is the metaclass,
not the class that uses it.
Deeper: what it costs
Six ways to fetch the same value, measured back to back in one process, Python 3.13.7, best of nine runs of 200,000 accesses:
| way | ns per access | against a plain attribute |
|---|---|---|
an ordinary attribute (__dict__) | 17.00 | ×1.00 |
a __slots__ slot | 17.04 | ×1.00 |
property | 35.06 | ×2.06 |
| a hand-written data descriptor in Python | 107.32 | ×6.31 |
| a hand-written non-data descriptor in Python | 119.56 | ×7.03 |
cached_property after the first access | 31.26 | ×1.84 |
The first thing this shows: "being a descriptor" costs nothing by itself. A
__slots__ slot is a full data descriptor and it is ×1.00. What is expensive
is a getter written in Python: property ×2.06, a hand-written descriptor
×6.31.
The second is more interesting, because it refutes an expectation. A warmed
cached_property should have matched a plain attribute: the value is already in
__dict__, a non-data descriptor is outranked by the dictionary, there is
nothing to ask it about. Measured — a steady ×1.84.
The reason is visible in the specialised bytecode:
def read(o):
return o.value
# after two hundred calls, dis.get_instructions(read, adaptive=True)
# an ordinary attribute -> LOAD_ATTR_INSTANCE_VALUE
# cached_property -> LOAD_ATTRFor an ordinary attribute the interpreter substitutes a specialised instruction that reads the value straight out of the instance. For an attribute with a descriptor still sitting on the type it cannot: the type has to be checked on every access, because that descriptor might have turned out to be a data one.
The practical conclusion is narrow and honest: cached_property saves you the
recomputation but does not turn the attribute into an ordinary one. If there is
nothing to compute and there are millions of accesses, a plain field set in
__init__ is almost twice as cheap.
Deeper: version history
| Version | Change | What it means for your code |
|---|---|---|
| 2.2 | PEP 252 introduces the descriptor protocol along with new-style classes. From this point on property, staticmethod and classmethod are not built-in exceptions to the rules but ordinary descriptors on top of a shared mechanism. | |
| 3.6 | __set_name__ appears: a descriptor learns its own name when the class is created. Before that the name had to be duplicated as a constructor argument — x = Field("x") — and letting the two drift apart was a routine bug. | |
| 3.8 | functools.cached_property appears — a non-data descriptor that puts its result into the instance's __dict__. It only works where a __dict__ exists: it is incompatible with __slots__. | |
| 3.10 | A staticmethod object becomes directly callable. classmethod has no such line in the documentation and its object stays non-callable — hence the asymmetry that makes @classmethod fail under someone else's decorator while @staticmethod works. Verified on 3.11–3.14. | |
| 3.12 | The class-wide lock is removed from cached_property. Previously the first computation in a threaded program serialised across every instance at once; now the body may run more than once, but it no longer blocks other instances. If the body must run exactly once, the lock is now yours to provide. | |
| 3.13 | classmethod stops unwrapping a nested descriptor: @classmethod over @property no longer yields the value. There is no exception — the type of the result changes, and you find out where the value gets used. |
How to answer in an interview
The short answer: a descriptor is an object that lives on the class and
intervenes in attribute access on an instance; the single criterion is that
its type has __get__, __set__ or __delete__. All the behaviour follows
from one line of precedence: a data descriptor (one with __set__ or
__delete__) outranks the instance dictionary, a non-data descriptor (only
__get__) is outranked by it. property, an ordinary method, staticmethod,
classmethod, a slot and cached_property already are descriptors.
That is enough to answer correctly. Beyond it is what you add if the interviewer digs.
If the interviewer digs deeper
If asked why this is worth knowing, the best answer derives two consequences
from that same line: cached_property caches because it is a non-data
descriptor and stops being consulted once the value is in __dict__; and a
descriptor placed on the instance does not work at all, because the lookup
goes to the class.
Next they ask
Is cached_property a separate caching mechanism?
No, it is a consequence of the precedence rule. It is a NON-data descriptor: on
the first access it puts the result into the instance __dict__, and the
instance dictionary outranks non-data descriptors — so it is never consulted
again.
A descriptor is declared on the metaclass. Will an instance see it?
No: C.x and c.x are two different lookups, differing by one line of
pseudocode. For an instance objtype is the class; for a class it is the
metaclass — so a metaclass descriptor is visible on the class and does not exist
for the instance.
Common misconceptions
Descriptors are a metaclass-and-framework thing; ordinary code has none
There are six of them in any class that has a method. An ordinary method, staticmethod, classmethod, property, a __slots__ slot and cached_property are all descriptors, and one line checks it: hasattr(type(Cls.__dict__["name"]), "__get__"). There is no separate "descriptor machinery" you could leave switched off.
To make a descriptor you need all three methods — __get__, __set__, __delete__
Any single one is enough, and the choice flips the behaviour. With only __get__, a write to the instance shadows the descriptor; add __set__ and it no longer does. A read-only data descriptor is made exactly this way: a __set__ that raises AttributeError — the body is beside the point, the presence of the method is not.
Descriptors are slow
A __slots__ slot is a data descriptor written in C and it costs 17.04 ns against 17.00 for a plain attribute — ×1.00. What is expensive is a getter in Python: property ×2.06, a hand-written descriptor ×6.31. Measured: six ways back to back in one process, 3.13.7.
After the first access cached_property is just an ordinary attribute
The value really is in __dict__, and the access is still more expensive: ×1.84 on 3.13.7. The interpreter cannot substitute the specialised LOAD_ATTR_INSTANCE_VALUE instruction, because a descriptor sits on the type and the type must be checked every time. Verified with the disassembler at adaptive=True.
A descriptor can live on the instance if that is more convenient
The protocol is only ever looked for on the type. An object with __get__ placed into obj.attr stays an ordinary object: the access returns the object itself, not the result of __get__. The same fact explains why __set_name__ fires at class creation — a descriptor exists as part of a class, not of an instance.
Practice
Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.
Practice · predict the output
class Data:
def __get__(self, obj, cls):
return "descriptor"
def __set__(self, obj, value):
pass
class NonData:
def __get__(self, obj, cls):
return "descriptor"
class C:
data = Data()
nondata = NonData()
c = C()
c.__dict__["data"] = "instance dict"
c.__dict__["nondata"] = "instance dict"
print(c.data)
print(c.nondata)Practice · estimate
Check yourself
A class has a descriptor with only __get__. __init__ does self.attr = 5. What does obj.attr return?
What measured this
The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A descriptor is an object that lives on the CLASS and decides for itself what to return when an INSTANCE is asked for an attribute. A dotted access is not always a field read: a property runs code when you touch it, and an ordinary function on a class turns into a bound method. Both are descriptors, and the language has no separate machinery for either.
- Hence the main consequence: there is exactly one criterion — the object's type has
__get__,__set__or__delete__.property, an ordinary method,staticmethod,classmethod, a__slots__slot andfunctools.cached_propertyalready are descriptors, which is to say you use them every day — and it is whyobj.method = ...goes through whileobj.prop = ...raises. - Beyond that is one line of precedence everything else follows from. A data descriptor (one with
__set__or__delete__) outranks the instance dictionary; a non-data descriptor (only__get__) is outranked by it. From that one line follow both whycached_propertycaches and why@classmethodbreaks under someone else's decorator — silently while the class is built, with aTypeErroron the first call. The protocol itself costs nothing: a slot is 17.04 ns against 17.00 for a plain attribute on 3.13.7; what is expensive is a getter written in Python (propertyis ×2.06).
In fact
- There are six of them in any class that has a method. An ordinary method,
staticmethod,classmethod,property, a__slots__slot andcached_propertyare all descriptors, and one line checks it:hasattr(type(Cls.__dict__["name"]), "__get__"). There is no separate "descriptor machinery" you could leave switched off. - Any single one is enough, and the choice flips the behaviour. With only
__get__, a write to the instance shadows the descriptor; add__set__and it no longer does. A read-only data descriptor is made exactly this way: a__set__that raisesAttributeError— the body is beside the point, the presence of the method is not. - A
__slots__slot is a data descriptor written in C and it costs 17.04 ns against 17.00 for a plain attribute — ×1.00. What is expensive is a getter in Python:property×2.06, a hand-written descriptor ×6.31. Measured: six ways back to back in one process, 3.13.7. - The value really is in
__dict__, and the access is still more expensive: ×1.84 on 3.13.7. The interpreter cannot substitute the specialisedLOAD_ATTR_INSTANCE_VALUEinstruction, because a descriptor sits on the type and the type must be checked every time. Verified with the disassembler atadaptive=True. - The protocol is only ever looked for on the type. An object with
__get__placed intoobj.attrstays an ordinary object: the access returns the object itself, not the result of__get__. The same fact explains why__set_name__fires at class creation — a descriptor exists as part of a class, not of an instance.
By version
- 2.2
- PEP 252 introduces the descriptor protocol along with new-style classes. From this point on
property,staticmethodandclassmethodare not built-in exceptions to the rules but ordinary descriptors on top of a shared mechanism.< - 3.6
__set_name__appears: a descriptor learns its own name when the class is created. Before that the name had to be duplicated as a constructor argument —x = Field("x")— and letting the two drift apart was a routine bug.<- 3.8
functools.cached_propertyappears — a non-data descriptor that puts its result into the instance's__dict__. It only works where a__dict__exists: it is incompatible with__slots__.<- 3.10
- A
staticmethodobject becomes directly callable.classmethodhas no such line in the documentation and its object stays non-callable — hence the asymmetry that makes@classmethodfail under someone else's decorator while@staticmethodworks. Verified on 3.11–3.14.< - 3.12
- The class-wide lock is removed from
cached_property. Previously the first computation in a threaded program serialised across every instance at once; now the body may run more than once, but it no longer blocks other instances. If the body must run exactly once, the lock is now yours to provide.< - 3.13
classmethodstops unwrapping a nested descriptor:@classmethodover@propertyno longer yields the value. There is no exception — the type of the result changes, and you find out where the value gets used.<
What is covered
- Base: why a dot is not always a field read
- Mechanism 1: what a descriptor is
- Mechanism 2: one precedence rule
- Mechanism 3: how a descriptor learns its own name
- Mechanism 4: you already use them
- Mechanism 5: why `@classmethod` breaks under someone else's decorator
- Mechanism 6: `cached_property` — the cache rests on precedence
- Deeper: the whole rule — the four branches of `object.__getattribute__`
- Deeper: the same lookup one floor up — a descriptor on the metaclass
- Deeper: what it costs
- Deeper: version history
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Check yourself
- What measured this
Sources & further reading
6 SOURCES
- Descriptor HowTo GuideOfficial documentation. The protocol and the precedence rule verbatim: «If an instance's dictionary has an entry with the same name as a data descriptor, the data descriptor takes precedence», and right there the mirror case for a non-data descriptor.https://docs.python.org/3.14/howto/descriptor.html
- Language reference — invoking descriptors and __set_name__Official documentation. The full lookup chain and the plain statement that methods are non-data descriptors: «Python methods (including those decorated with staticmethod and classmethod) are implemented as non-data descriptors». The condition under which __set_name__ fires comes from the same page.https://docs.python.org/3.14/reference/datamodel.html#invoking-descriptors
- Lib/functools.py — cached_property.__get__CPython source code. Lines 1110–1134: the value is placed straight into instance.__dict__ and read back from there on the next access. The cache does not work on its own — it works because a non-data descriptor is outranked by the dictionary. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Lib/functools.py
- Objects/object.c — _PyObject_GenericGetAttrWithDictCPython source code. The function the precedence rule is actually written in: the type is searched first, then the data-descriptor check, then the instance dictionary, and only then a non-data descriptor's __get__. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Objects/object.c
- Built-in functions — staticmethod and classmethodOfficial documentation. «Changed in version 3.10: Static methods are now callable» — and, next to classmethod, the absence of any such line. That asymmetry is where a failing @classmethod under someone else's decorator comes from.https://docs.python.org/3.14/library/functions.html
- PEP 252 — Making Types Look More Like ClassesPEP. The document that brought the descriptor protocol into the language (Python 2.2, Guido van Rossum, Final). Needed to show this is not a late ornament but the foundation of the attribute model.https://peps.python.org/pep-0252/