Deep Engineering
Advanced·Published·3.11 · 3.12 · 3.13 · 3.14·35 MIN

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).

Where to start
Before this lesson it is enough to understand
  • a class declares methods, while an instance holds its own data;
  • obj.attr reads an attribute and obj.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.
You do not need to know in advance
  • __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

language contractLanguage guarantee: a descriptor is an object whose type implements __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.

Descriptor HowTo Guide

"Any of these" means __get__, __set__, __delete__. The smallest possible descriptor is three lines:

PYTHON
class Loud:
    def __get__(self, obj, owner=None):
        return "somebody asked for me"
 
class Thing:
    attr = Loud()
 
print(Thing().attr)      # somebody asked for me

This 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.

PYTHON
t = Thing()
t.other = Loud()
print(t.other)           # <__main__.Loud object at 0x...> — just an object

Mechanism 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.

Descriptor HowTo Guide

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.

Descriptor HowTo Guide

It is checked by an experiment in which everything is identical except the presence of __set__:

PYTHON
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 won

The 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.

Descriptor HowTo Guide

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.

Language reference — object.__set_name__
PYTHON
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)yesnononon-data
propertyyesyesyesdata
staticmethodyesnononon-data
classmethodyesnononon-data
a __slots__ slotyesyesyesdata
functools.cached_propertyyesnononon-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.

Language reference — invoking descriptors

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:

PYTHON
class Sample:
    @staticmethod
    def static(): pass
    @classmethod
    def klass(cls): pass
 
print(callable(Sample.__dict__["static"]))   # True
print(callable(Sample.__dict__["klass"]))    # False

A 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__:

PYTHON
# 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 val

The 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:

PYTHON
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 asked

The 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__

implementation detail · CPython 3.13The four branches are how the priority rule is written in the source of 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:

Descriptor HowTo Guide
PYTHON
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.

Descriptor HowTo Guide

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).

Descriptor HowTo Guide

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.

Ibid.

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

measured observationbench/descriptors/cost.py, CPython 3.13.7. Measured on one machine; the ratio between access paths is what carries meaning.

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:

wayns per accessagainst a plain attribute
an ordinary attribute (__dict__)17.00×1.00
a __slots__ slot17.04×1.00
property35.06×2.06
a hand-written data descriptor in Python107.32×6.31
a hand-written non-data descriptor in Python119.56×7.03
cached_property after the first access31.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:

PYTHON
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_ATTR

For 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

VersionChangeWhat it means for your code
2.2PEP 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.8functools.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.10A 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.12The 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.13classmethod 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

Next they ask

Is cached_property a separate caching mechanism?

Short answer

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.

Next they ask

A descriptor is declared on the metaclass. Will an instance see it?

Short answer

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

Claim

Descriptors are a metaclass-and-framework thing; ordinary code has none

Actually

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.

Claim

To make a descriptor you need all three methods — __get__, __set__, __delete__

Actually

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.

Claim

Descriptors are slow

Actually

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.

Claim

After the first access cached_property is just an ordinary attribute

Actually

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.

Claim

A descriptor can live on the instance if that is more convenient

Actually

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

A class has two descriptors: one with __set__, one with only __get__. Both names are written straight into the instance dictionary. What does this code print?
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

A cached_property after the first access: the value is already in __dict__, and a non-data descriptor is outranked by the dictionary, so there is nothing left to ask it. How many times more expensive is that access than a plain attribute?
times

Check yourself

Question 1 of 4

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.

Sources & further reading

6 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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/