What happens on o.x: descriptors, the MRO and the type cache
A dot is not a field read. It is a protocol of four mechanisms, and one line of precedence orders them. From that line follow why assigning to a property without a setter raises AttributeError, why __getattr__ sometimes never fires, and why reaching an inherited attribute does not depend on how deep the inheritance goes — as long as the type version cache holds. And on 3.14 there turns out to be a way to switch that cache off without knowing it exists.
Full technical treatment
TL;DR
- The dot in
o.xis not a field read but a search over four places in a strict order: a data descriptor on the class → the instance dictionary → a non-data descriptor → a plain class attribute. Nearly everything else in the topic follows from that order. - A method,
property,staticmethod,classmethod, a__slots__slot,cached_property— all of these are descriptors, objects of the same construction. There is no separate "method machinery" in Python. - Inheritance depth is not something you pay for: the result of the lookup sits in a cache tied to the type's version. Two classes in the chain and fifty-one take the same ~11 ns. What is expensive is not inheritance but editing classes while the program runs: that wipes the cache for the class and for every subclass.
- With multiple inheritance the order of classes is not "left to right": the shared ancestor moves to the tail, and in a diamond the right branch wins.
- A trap that appeared in 3.14: a class whose metaclass overrides
mro()loses that cache — along with every subclass, which never even mentions the metaclass.
The dot in o.x looks like reading a field, but it is a search: the interpreter
asks several places in turn and takes the first answer. The order of those
places is the whole subject.
The order everything else grows from
There are four places, and the priority runs like this:
- a data descriptor on the class — an object that can both hand back a value and intercept assignment;
- the instance's own dictionary — whatever you put there with
self.x = ...; - a non-data descriptor — one that can only hand back;
- an ordinary class attribute.
The difference between the first and the third is exactly one thing: can the object intercept a write. If it can, it outranks the instance dictionary; if not, the dictionary outranks it.
Things usually learned separately follow from that single line. @property can
intercept a write, so it cannot be overridden by putting a value on the
instance. functools.cached_property cannot — which is why it caches: on the
first access it puts the result on the instance, and from then on the instance
dictionary wins and it is never consulted again.
Everything familiar is a descriptor
An ordinary method, property, staticmethod, classmethod, a __slots__
slot, cached_property — these are all objects of the same construction. There
is no separate "method machinery" in Python: a method is a function living on
the class that has a __get__, and on access it hands back itself bound to the
instance.
The order of classes is not "left to right"
When there are several classes, the interpreter walks the __mro__ list. It is
not built as "the whole left branch first, then the right one": the shared
ancestor moves to the end.
class Base:
def m(self): return "Base"
class L(Base): pass
class R(Base):
def m(self): return "R"
class D(L, R): pass
print(D().m()) # R, not BaseHad the walk gone down the left branch, Base would have come before R. It
comes later — and R wins.
Sometimes no such order exists at all, and then the class is simply not created:
TypeError: Cannot create a consistent method resolution order.
Inheritance depth is not something you pay for
You would think the longer the chain of classes, the longer the lookup. In practice, no: the result of the lookup sits in a cache tied to the type's version.
Measured: reaching an inherited attribute takes about 11 nanoseconds and does not depend on the length of the chain — two classes and fifty-one classes take the same time. Start invalidating the cache on every step and growth appears: 181 nanoseconds instead of 11 on the long chain.
Hence the practical part: deep inheritance is not slow by itself, while editing classes while the program runs is expensive. It wipes the cache not only for that class but for every subclass.
One trap that appeared in 3.14
Every class has a class of its own — the one that creates it. It is called a
metaclass, and normally it is the built-in type. You can write your own and
override its mro() method, the one that decides in what order base classes
are consulted.
If such a class sits at the base of a hierarchy, then on Python 3.14 the cache
is switched off for it — and for every subclass too. On a chain of fifty-one
classes an attribute access then costs 118.4 ns instead of 6.4 — eighteen times
more; on a short chain of two classes the gap is smaller, 15.3 against 6.5.
Reading your own code will not find it: the subclass is declared as a plain
class Sub(Base) and never mentions the metaclass.
On 3.12 and 3.13 there is nothing of the sort. The technique is rare — no
module of the standard library overrides mro() — but it does turn up in plugin
libraries and ORMs.
A small thing that contradicts a habit
"Hoist the attribute lookup out of the loop" is right for module functions and
wrong for instance methods. Measured: obj.m() in full takes 16.3 ns, while
obj.m alone takes 24.6. Taking without calling costs more than taking and
calling: in the one-line form the bound method object is never created.
TL;DR
A dot is not a field read. It is a protocol, and one line orders it: a data
descriptor outranks the instance dictionary, and the instance dictionary
outranks a non-data descriptor. The descriptors turn out to be every familiar
member of a class — an ordinary method, property, staticmethod,
classmethod, a __slots__ slot, cached_property.
The order in which classes are visited comes from C3, and it is not "the
whole left branch first". In the diamond class D(L, R) the shared ancestor
moves to the tail: ['D', 'L', 'R', 'Base', 'object'], and the method comes
from R.
The length of that chain costs nothing, because its result sits in the type
version cache. Measured: with the cache intact an access takes about 11 ns and
does not depend on __mro__ depth at all (a slope of +0.02 ns per level across
depths from 2 to 51). Invalidate the cache and linear growth appears, +3.31 ns
per level: 180.8 ns against 11.9 on a chain of fifty-one classes.
And the main thing: on 3.14 a metaclass that overrides mro() — even one
returning exactly super().mro() — switches the cache off for its class and
for every subclass. On a chain of fifty-one classes that is 118.4 ns against
6.4 for an ordinary type, and the one who pays wrote a plain class Sub(Base)
and knows nothing about any metaclass. On 3.12 and 3.13 there is no such effect.
One dot, four mechanisms
o.x in the source becomes a single LOAD_ATTR instruction. Behind it stands
the type's tp_getattro, which for ordinary classes is
object.__getattribute__ — and that one is not simple: it has to assemble an
answer out of four sources with different priorities.
The documentation states the order in one sentence: 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
The rest of this article is about what stands behind each word of that sentence and what it costs.
The precedence rule: two words that decide everything
A descriptor is an object that lives on the class and whose type has
__get__, __set__ or __delete__. That is the only criterion; there is no
separate "descriptor machinery" beyond it.
The split into two kinds runs along one line: does it have __set__ (or
__delete__)? If it does, it is a data descriptor; if not, a non-data
descriptor. The documentation puts the consequence plainly: Data descriptors always
override instance dictionaries. Non-data descriptors may be overridden by
instance dictionaries
.
One experiment settles it, with both names written straight into the instance
__dict__ — so no assignment ran and there was nothing to intercept:
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) # descriptor
print(c.nondata) # instance dictThe same action — a write into the instance dictionary — means nothing in one
case and decides everything in the other. The only difference is whether the
descriptor's type has __set__.
"Always override" — almost always
That "always override" deserves a qualification, because classification and
read precedence are two different rules and they do not coincide everywhere. A
type is a data descriptor if it has __set__ or __delete__; __get__ is
not part of that condition. But object.__getattribute__ goes to the descriptor
ahead of the instance dictionary only if it is classified as a data descriptor
and has something to hand back.
A type with nothing but __delete__ falls exactly into that gap:
| the descriptor's type has | del obj.x | obj.x with a value in __dict__ |
|---|---|---|
__get__ | goes to the dictionary | from the dictionary |
__get__ + __set__ | AttributeError | from the descriptor |
__get__ + __delete__ | to the descriptor | from the descriptor |
__delete__ only | to the descriptor | from the dictionary |
The last row is the exception to "always override": the descriptor does
intercept deletion — so it really is classified as a data descriptor — while a
read never reaches it, because it has nothing to hand back. Almost nobody
writes this, which is why the documentation's sentence works in practice; but
it cannot be leaned on as a definition. Checked on 3.12.3, 3.13.7 and 3.14.7:
bench/attr-lookup/descriptor_kinds.py.
__getattr__ is not called by what you think
The last item in the chain stands apart, and it is the source of the question
"why is my __getattr__ never called". It is not in __getattribute__ at all:
Note, there is no
.__getattr__() hook in the __getattribute__() code. That
is why calling __getattribute__() directly or with super().__getattribute__
will bypass __getattr__() entirely
What calls it is not the lookup but whoever started the lookup: Instead, it is
the dot operator and the
.getattr() function that are responsible for invoking
__getattr__() whenever __getattribute__() raises an AttributeError
From here it is easy to take a step that looks like a direct consequence and is
wrong: that a custom __getattribute__ delegating the lookup to super()
thereby switches __getattr__ off. It does not — and this is worth checking
rather than taking on trust:
class C:
def __getattribute__(self, name):
return super().__getattribute__(name)
def __getattr__(self, name):
return "from __getattr__"
C().missing # 'from __getattr__' — it fired
C().__getattribute__("missing") # AttributeErrorThe difference is who is asking. The fallback sits one level above
__getattribute__ itself, and what triggers it is the machinery of the ordinary
dot — every time __getattribute__, custom or inherited, lets an
AttributeError escape. Delegating to super() does not swallow that
exception, it passes it on, and the dot picks it up as usual.
The sentence quoted above is still true — it is simply about something else: about the direct call, which has no dot above it. The trap is not in the documentation but in carrying its conclusion from a single call over to the whole expression.
What switches __getattr__ off is not delegation but swallowing: a
__getattribute__ that catches the AttributeError itself and returns or
raises something of its own. Then nothing escapes, and the dot has nothing to
start the fallback with. All three cases are printed for 3.12.3, 3.13.7 and
3.14.7 by bench/attr-lookup/getattr_hook.py; they behave identically.
Methods, property, slots and cached_property are descriptors
Not "everything in a class": x = 42 in a class body is an ordinary attribute
with no protocol behind it. But almost every familiar "special" member is
reached through one. inspect.getattr_static pulls the object out
of the class without running the protocol, and all six familiar members turn
out to be built the same way:
| what is written in the class | what actually sits there | __get__ | kind |
|---|---|---|---|
| an ordinary method | function | yes | non-data |
@property | property | yes | data |
@staticmethod | staticmethod | yes | non-data |
@classmethod | classmethod | yes | non-data |
__slots__ | member_descriptor | yes | data |
@functools.cached_property | cached_property | yes | non-data |
Two behaviours usually learned separately follow from this table.
Why cached_property caches. It is a NON-data descriptor. On the first
access its __get__ puts the result into the instance __dict__ — and that is
all: from then on the instance dictionary outranks it and it is never consulted
again. The caching is not a mechanism but a consequence of precedence.
Why property also intercepts assignment. It is a data descriptor:
__set__ is always there, even when no setter is declared — which is exactly
why assigning to a property without a setter raises AttributeError instead of
quietly creating an instance attribute.
Where the order lives: the MRO and C3
Classes are visited not "left to right and downwards" but in __mro__ order,
which C3 builds. The formula from the documentation:
L[C(B1 ... BN)] = C + merge(L[B1] ... L[BN], B1 ... BN)
The merge rule is take the head of the first list, i.e L[B1][0]; if this head is
not in the tail of any of the other lists, then add it to the linearization of
C and remove it from the lists in the merge, otherwise look at the head of the
next list and take it, if it is a good head
.
The diamond shows how that differs from intuition:
class Base:
def m(self): return "Base"
class L(Base): pass
class R(Base):
def m(self): return "R"
class D(L, R): pass
print([c.__name__ for c in D.__mro__])
print(D().m())['D', 'L', 'R', 'Base', 'object']
R
The shared ancestor moved to the tail. Had the walk gone "the whole L
branch first", Base would have come second and the answer would have been 'Base'.
The rule that a head must not appear in the tails of the other lists forbids
exactly that: Base is in the tail of R's linearization, so it cannot be
taken before R.
When no order exists
Not all classes admit a linearization
— and then the class simply is not
created:
class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class Impossible(A, B): passTypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y
A requires X before Y, B the other way round. This is not an
implementation limit but the absence of a solution: no order respects both
requirements.
How long these chains are in real life
Short. For the types you deal with every day, __mro__ is two to four entries:
| type | depth | __mro__ |
|---|---|---|
int, str, list, dict | 2 | the type and object |
bool | 3 | bool, int, object |
Exception | 3 | Exception, BaseException, object |
ValueError | 4 | plus Exception and BaseException |
io.StringIO | 4 | StringIO, _TextIOBase, _IOBase, object |
The depths of 21 and 51 in the measurements below are a model, not typical code. They exist to make the slope visible: on a chain of three classes the difference between "cache" and "no cache" cannot be told apart.
The type version cache
The __mro__ walk does not happen on every access. A type carries a
tp_version_tag field, and the result of looking up a "type, name" pair lands
in a table shared across the interpreter. In the 3.13.7 headers it is declared
like this:
// Type attribute lookup cache: speed up attribute and method lookups,
// see _PyType_Lookup().
struct type_cache_entry {
unsigned int version; // initialized from type->tp_version_tag
PyObject *name; // reference to exactly a str or None
PyObject *value; // borrowed reference or NULL
};
#define MCACHE_SIZE_EXP 124096 slots, each holding a type version, a name and the
value found. If the version and the name match, the answer comes from here and
__mro__ is never touched.
There is no official documentation for this cache. There is exactly one
sentence, and it lives in the description of a C API function: Invalidate the
internal lookup cache for the type and all of its subtypes. This function must
be called after any manual modification of the attributes or base classes of
the type
. Everything below is derived from the headers and from
measurement, and that is worth keeping in mind: this is an implementation
detail, not a guarantee of the language.
The measurement works like this: the same o.m at four __mro__ depths, in two
modes. In the first nothing is touched and the cache lives. In the second
something is assigned to the class on every iteration — enough to reset the
version. The cost of the assignment itself is measured by a separate row and
subtracted; without that the measurement would be showing the price of
setattr rather than the price of a miss.
__mro__ depth | cache intact, 3.13.7 | cache invalidated, 3.13.7 |
|---|---|---|
| 2 | 10.8 ns | 18.7 ns |
| 6 | 10.9 ns | 29.2 ns |
| 21 | 11.0 ns | 87.4 ns |
| 51 | 11.9 ns | 180.8 ns |
The slope between the extreme points: +0.02 ns per level with the cache intact, +3.31 ns without it. The first number is the claim "does not depend": against a spread of a couple of nanoseconds there is no trend at all, while the chain length changes twenty-five-fold.
On 3.14.7 the picture is the same and the intact-cache row is cleaner still: 12.0 / 12.3 / 12.0 / 12.0 ns. Without the cache: 21.9 / 31.4 / 70.9 / 126.8, a slope of +2.14 ns per level. Those quartets must not be compared with the previous ones: the builds differ by more than the language version. Compare rows within one column — and there the conclusion is identical.
What invalidates the cache
A type's version resets when the type itself changes: assigning an attribute to
the class, deleting one, replacing __bases__. It resets for every subtype
too — exactly as the PyType_Modified documentation promises.
Hence a practical consequence usually posed as a riddle: "we added some runtime
monkey-patching and everything got slower". Editing a class in a hot loop costs
more than its own price: it wipes what had accumulated for that type and all its
subclasses, and the next lookup walks __mro__ from scratch.
The reassuring flip side: ordinary code does not invalidate the cache. Assigning an attribute to an instance does not touch the type's version; classes are usually not modified after import; and so inheritance depth does not count.
The 3.14 trap: your own mro() switches the cache off
What follows is not in the documentation, and it is visible only by measurement.
In 3.13 a tp_versions_used field appeared on types — bare, with no comment.
In 3.14 an explanation and a constant stand beside it:
/* Number of tp_version_tag values used.
* Set to _Py_ATTR_CACHE_UNUSED if the attribute cache is
* disabled for this type (e.g. due to custom MRO entries).
* Otherwise, limited to MAX_VERSIONS_PER_CLASS (defined elsewhere).
*/
uint16_t tp_versions_used;
};
#define _Py_ATTR_CACHE_UNUSED (30000) // (see tp_versions_used)if the attribute cache is disabled for this type (e.g. due to custom MRO
entries)
— that "e.g." turned out to be measurable.
A metaclass that overrides mro() deprives its class of the cache. And it does
not have to override it meaningfully:
class OwnMRO(type):
def mro(cls):
return super().mro() # the order does NOT changeMeasured on 3.14.7, three configurations at three chain depths:
| configuration | depth 2 | depth 21 | depth 51 |
|---|---|---|---|
ordinary type | 6.5 ns | 6.4 ns | 6.4 ns |
metaclass without mro() | 6.4 ns | 6.4 ns | 6.4 ns |
metaclass with its own mro() | 15.3 ns | 63.8 ns | 118.4 ns |
The middle row is not there for symmetry: without it the slowdown
could be blamed on metaclasses in general. It is flat — so the cause is the
overridden mro() specifically.
On 3.13.7 all three rows are flat, 5.8–6.0 ns. On 3.12.3 they are flat too, 12.7–12.9. The behaviour changed in 3.14.
Why this is worse than it looks
The effect is inherited. One class whose metaclass overrides mro()
somewhere near the base of a hierarchy is enough: the subclass is declared as a plain
class Sub(Base), Python derives the metaclass from the bases by itself, and
the cache is lost by someone who does not even know a metaclass is involved.
Measured: a chain of fifty-one classes with mro() overridden only at the root
and every subclass declared with the ordinary type — 118.7 ns, the same as the root
itself to within the spread of the measurement.
So the cost appears in someone else's file, where nothing unusual is written,
and it is found neither by reading that file nor by a profiler, which will only
say "a lot of time in LOAD_ATTR".
The technique is rare. In the standard library of the 3.12.3, 3.13.7 and
3.14.7 builds there is not a single override of mro() — that is a syntax-tree
scan of all 574, 632 and 655 of its modules respectively,
bench/attr-lookup/mro_stdlib_scan.py; run it on your own build, the answer
may differ. One caveat is mandatory: the test suite is not part of these
builds, so about tests there is nothing to say.
Such an mro() is needed by code that deliberately steers the resolution
order — metaclasses assembling a hierarchy on the fly, for instance. I will not
name libraries here: I have no verified list to hand, and a plausible-sounding
enumeration is invention too, only better dressed.
The practical conclusion is narrow and checkable: if you override mro() and
target 3.14, make sure it is not at the base of a hierarchy other people build
on. And if you inherit from someone else's class and attribute access
suddenly costs an order of magnitude more, one line settles it:
type(type(obj)).mro is type.mro # False → the metaclass overrode mro()This is how to check it, rather than by reading someone else's file: the
subclass is declared as a plain class Sub(Base) and never mentions the
metaclass, but its metaclass is the same one — and the check sees that.
A bound method is not always created
The other half of the price of a dot is what happens once the value is found. A
function in a class is a non-data descriptor, and its __get__ creates a bound
method object. But not always:
| what is measured | 3.13.7 |
|---|---|
obj.m() — take and call in one go | 16.3 ns |
obj.m — take only | 24.6 ns |
bound() — call one already taken | 18.1 ns |
Taking an attribute without calling it is more expensive than taking and
calling. The reason is not the measurement: the interpreter specialises the
"take then call" pair and passes self as a separate argument, so no bound
method object is created at all. Split the same work across two lines and it has
to appear, because it is put into a variable: 24.6 + 18.1 against 16.3, that is
+26.4 ns for nothing.
Hence a practical conclusion — but strictly within the bounds of the
measurement. What was tested is an ordinary Python method on CPython 3.13.7 and
3.14.7, and on both builds obj.method() came out cheaper than a bound method
taken in advance (+26.4 and +23.9 ns for the split form). So the usual "hoist
the attribute lookup out of the loop" is not an automatic optimisation: for an
instance method it turns something free into something paid for. The habit
stays right for module.func, where a real lookup is hoisted.
Beyond the measurement this conclusion does not stretch, and it should not be
stretched: for C functions, for objects with a custom __getattribute__, for
proxy wrappers and for other Python implementations the ratio may differ.
Measure your own hot path.
super(): the price of a level
| what is measured | 3.13.7 |
|---|---|
direct.m(), with Base.m(self) inside | 31.5 ns |
mid.m(), with super().m() inside | 43.6 ns |
deep.m(), two levels of super() | 75.2 ns |
One level of super() against a direct reference to the base class is +12.1
ns. The next level adds another 31.5, but that cannot be charged to super()
in full: a whole Mid.m frame stands between deep.m and Base.m, and most of
the difference is the ordinary price of an extra Python call.
The magnitude is such that there is nothing to optimise here: twelve nanoseconds
do not pay for losing the cooperative inheritance super() exists for. It is
worth knowing for something else — so as not to look for the cause in super()
when the profiler is showing microseconds.
What follows for code
Inheritance depth costs nothing by itself. As long as classes are not edited at runtime, the cache holds the result, and a chain of fifty classes costs what a chain of two costs. The argument "do not inherit deeply, it is slow" is not supported by measurement.
Editing classes at runtime is expensive, and not where it looks. The cost is not the assignment but the fact that it wipes the cache for the type and every subclass.
property is a data descriptor, and its __set__ is always there. Hence
the AttributeError on assignment without a setter, and the impossibility of
"overriding" a property by writing into the instance __dict__.
cached_property does not work when instances have no __dict__ — there
is nowhere left to put the value. That happens, for instance, when __slots__
are declared all the way up the chain. A direct consequence of it being a
non-data descriptor.
In a hot loop, do not hoist an instance method into a variable — on 3.13 and 3.14 at least. Measured above: it adds the creation of a bound method object that the one-line form never performs. For other kinds of callable, measure your own path.
A custom mro() on 3.14 is a decision with a price. And the one who pays is
not the one who wrote it.
Version history
| Version | Change | What it means for code |
|---|---|---|
| 3.12 | The type has no tp_versions_used field yet. Every configuration in the measurement is flat: the cache works the same for an ordinary type and for a class with an overridden mro(). | |
| 3.13 | The tp_versions_used field appears in cpython/object.h — declared bare, with no comment, and with no effect on behaviour: the measurement is still flat everywhere. functools.partial in a class body still behaves like an ordinary attribute, but warns: functools.partial will be a method descriptor in future Python versions; wrap it in staticmethod() if you want to preserve the old behavior. | |
| 3.14 | The same field acquires a meaning: the constant _Py_ATTR_CACHE_UNUSED and a comment about the cache being disabled. A class with an overridden mro() loses the attribute cache — and every subclass loses it too. functools.partial becomes a method descriptor (gh-121027): code that worked with a warning on 3.13 fails with TypeError, because self now arrives in the call as well. |
What measured this
The figures in this article come from these scripts. Each opens straight from here, together with the record of a run: what it was measured on, what came out and with what spread.
With timings:
Without timings — these compare values and sequences of calls:
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 dot is not a field read. It is a protocol, and one line orders it: a data descriptor outranks the instance dictionary, and the instance dictionary outranks a non-data descriptor. The descriptors turn out to be every familiar member of a class — an ordinary method,
property,staticmethod,classmethod, a__slots__slot,cached_property. - The order in which classes are visited comes from C3, and it is not "the whole left branch first". In the diamond
class D(L, R)the shared ancestor moves to the tail:['D', 'L', 'R', 'Base', 'object'], and the method comes fromR. - The length of that chain costs nothing, because its result sits in the type version cache. Measured: with the cache intact an access takes about 11 ns and does not depend on
__mro__depth at all (a slope of +0.02 ns per level across depths from 2 to 51). Invalidate the cache and linear growth appears, +3.31 ns per level: 180.8 ns against 11.9 on a chain of fifty-one classes. - And the main thing: on 3.14 a metaclass that overrides
mro()— even one returning exactlysuper().mro()— switches the cache off for its class and for every subclass. On a chain of fifty-one classes that is 118.4 ns against 6.4 for an ordinary type, and the one who pays wrote a plainclass Sub(Base)and knows nothing about any metaclass. On 3.12 and 3.13 there is no such effect.
In fact
- Not while the type version cache holds. Measured at
__mro__depths of 2, 6, 21 and 51: 10.8 / 10.9 / 11.0 / 11.9 ns, a slope of +0.02 ns per level — no trend at all. Linear growth only appears when the cache is invalidated on every access: the same four depths then give 18.7 / 29.2 / 87.4 / 180.8 ns, +3.31 ns per level. What is expensive is not inheritance but editing classes at runtime. - It is called not by the lookup but by whoever started it. There is no reference to
__getattr__inside__getattribute__at all — the documentation says so outright — and what calls it is the dot operator andgetattr(), once__getattribute__has raisedAttributeError. From here people take one more step, and it is wrong: that a custom__getattribute__delegating the lookup tosuper()thereby switches__getattr__off. It does not: the fallback sits above__getattribute__itself, and delegation does not swallow the exception but lets it escape — the dot picks it up as usual. What bypasses__getattr__is only the DIRECT callobj.__getattribute__(name), which has no dot above it; what switches it off is an override that catches theAttributeErroritself and answers for it. Identical on 3.12.3, 3.13.7 and 3.14.7. - It does not: the shared ancestor moves to the tail.
D.__mro__is['D', 'L', 'R', 'Base', 'object'], andD().m()returns'R', not'Base'. The C3 rule forbids taking a head that appears in the tail of another list:Baseis in the tail ofR's linearization, so it cannot come beforeR. Identical on 3.12.3, 3.13.7 and 3.14.7. - It is a consequence of precedence, not a mechanism.
cached_propertyis a NON-data descriptor: on the first access it puts the value into the instance__dict__, and the instance dictionary outranks non-data descriptors, so it is never consulted again. The same fact explains why it does not work with__slots__: there is nowhere to put the value. - For an instance method it is the opposite. Measured on 3.13.7:
obj.m()in full 16.3 ns,obj.malone 24.6 ns, calling one already taken 18.1. Taking and calling separately costs 26.4 ns more than the one-line form, because in the one-line form no bound method object is created at all — the interpreter passesselfseparately. The habit is right formodule.func, where a real lookup is hoisted. On 3.14.7 the same: 17.1 / 23.5 / 17.5, the split form costing 23.9 ns more. The conclusion does not stretch past the measurement: what was tested is an ordinary Python method, and for C functions, proxy wrappers and other Python implementations the ratio may differ. - It is not. The whole cache gets one official sentence, and that one lives in the description of the C API function
PyType_Modified: “Invalidate the internal lookup cache for the type and all of its subtypes”. Neithertp_version_tag, nor the table size, nor the conditions under which it is switched off appear in the public documentation — all of that is derived from build headers and from measurement, and should be treated as an implementation detail rather than a guarantee of the language. - Not by itself. A metaclass that overrides nothing takes the same time as an ordinary
type, at every depth tested and on all three versions: on 3.14.7 both rows give 6.4 ns, on 3.13.7 5.8–6.0, on 3.12.3 12.7–12.9. What slows things down is one specific act — an overriddenmro()— and only on 3.14: there the class loses the attribute cache and access starts growing with__mro__depth, up to 118.4 ns against 6.4. The middle row, "metaclass without mro()", is in the measurement precisely so that the effect cannot be blamed on metaclasses in general.
By version
- 3.12
- The type has no
tp_versions_usedfield yet. Every configuration in the measurement is flat: the cache works the same for an ordinary type and for a class with an overriddenmro().< - 3.13
- The
tp_versions_usedfield appears incpython/object.h— declared bare, with no comment, and with no effect on behaviour: the measurement is still flat everywhere.functools.partialin a class body still behaves like an ordinary attribute, but warns: functools.partial will be a method descriptor in future Python versions; wrap it in staticmethod() if you want to preserve the old behavior.< - 3.14
- The same field acquires a meaning: the constant
_Py_ATTR_CACHE_UNUSEDand a comment about the cache being disabled. A class with an overriddenmro()loses the attribute cache — and every subclass loses it too.functools.partialbecomes a method descriptor (gh-121027): code that worked with a warning on 3.13 fails withTypeError, becauseselfnow arrives in the call as well.<
What is covered
- One dot, four mechanisms
- The precedence rule: two words that decide everything
- Methods, property, slots and cached_property are descriptors
- Where the order lives: the MRO and C3
- The type version cache
- The 3.14 trap: your own `mro()` switches the cache off
- A bound method is not always created
- `super()`: the price of a level
- What follows for code
- Version history
- What measured this
Common misconceptions
Deep inheritance slows attribute access down
Not while the type version cache holds. Measured at __mro__ depths of 2, 6, 21 and 51: 10.8 / 10.9 / 11.0 / 11.9 ns, a slope of +0.02 ns per level — no trend at all. Linear growth only appears when the cache is invalidated on every access: the same four depths then give 18.7 / 29.2 / 87.4 / 180.8 ns, +3.31 ns per level. What is expensive is not inheritance but editing classes at runtime.
__getattr__ is called when an attribute is not found
It is called not by the lookup but by whoever started it. There is no reference to __getattr__ inside __getattribute__ at all — the documentation says so outright — and what calls it is the dot operator and getattr(), once __getattribute__ has raised AttributeError. From here people take one more step, and it is wrong: that a custom __getattribute__ delegating the lookup to super() thereby switches __getattr__ off. It does not: the fallback sits above __getattribute__ itself, and delegation does not swallow the exception but lets it escape — the dot picks it up as usual. What bypasses __getattr__ is only the DIRECT call obj.__getattribute__(name), which has no dot above it; what switches it off is an override that catches the AttributeError itself and answers for it. Identical on 3.12.3, 3.13.7 and 3.14.7.
In the diamond class D(L, R) the walk goes down the whole L branch first
It does not: the shared ancestor moves to the tail. D.__mro__ is ['D', 'L', 'R', 'Base', 'object'], and D().m() returns 'R', not 'Base'. The C3 rule forbids taking a head that appears in the tail of another list: Base is in the tail of R's linearization, so it cannot come before R. Identical on 3.12.3, 3.13.7 and 3.14.7.
cached_property is a separate caching mechanism
It is a consequence of precedence, not a mechanism. cached_property is a NON-data descriptor: on the first access it puts the value into the instance __dict__, and the instance dictionary outranks non-data descriptors, so it is never consulted again. The same fact explains why it does not work with __slots__: there is nowhere to put the value.
Hoisting obj.method into a variable before a loop is an optimisation
For an instance method it is the opposite. Measured on 3.13.7: obj.m() in full 16.3 ns, obj.m alone 24.6 ns, calling one already taken 18.1. Taking and calling separately costs 26.4 ns more than the one-line form, because in the one-line form no bound method object is created at all — the interpreter passes self separately. The habit is right for module.func, where a real lookup is hoisted. On 3.14.7 the same: 17.1 / 23.5 / 17.5, the split form costing 23.9 ns more. The conclusion does not stretch past the measurement: what was tested is an ordinary Python method, and for C functions, proxy wrappers and other Python implementations the ratio may differ.
The layout of the type cache is documented
It is not. The whole cache gets one official sentence, and that one lives in the description of the C API function PyType_Modified: “Invalidate the internal lookup cache for the type and all of its subtypes”. Neither tp_version_tag, nor the table size, nor the conditions under which it is switched off appear in the public documentation — all of that is derived from build headers and from measurement, and should be treated as an implementation detail rather than a guarantee of the language.
A metaclass by itself slows attribute access down
Not by itself. A metaclass that overrides nothing takes the same time as an ordinary type, at every depth tested and on all three versions: on 3.14.7 both rows give 6.4 ns, on 3.13.7 5.8–6.0, on 3.12.3 12.7–12.9. What slows things down is one specific act — an overridden mro() — and only on 3.14: there the class loses the attribute cache and access starts growing with __mro__ depth, up to 118.4 ns against 6.4. The middle row, "metaclass without mro()", is in the measurement precisely so that the effect cannot be blamed on metaclasses in general.
Knowledge check
A class has a property named x. Bypassing assignment, obj.__dict__['x'] = 1 is written straight into the instance dictionary. What does obj.x return?
Sources & further reading
7 SOURCES
- Descriptor Guide — Invocation from an instanceOfficial documentation. The precedence rule in one sentence: “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”. And separately, why `__getattr__` sometimes does not fire: “Note, there is no `__getattr__()` hook in the `__getattribute__()` code. That is why calling `__getattribute__()` directly or with `super().__getattribute__` will bypass `__getattr__()` entirely”.https://docs.python.org/3/howto/descriptor.html
- The Python 2.3 Method Resolution OrderOfficial documentation. The C3 formula as the document states it: `L[C(B1 ... BN)] = C + merge(L[B1] ... L[BN], B1 ... BN)`. The head-selection rule: “take the head of the first list, i.e L[B1][0]; if this head is not in the tail of any of the other lists, then add it to the linearization of C and remove it from the lists in the merge, otherwise look at the head of the next list and take it, if it is a good head”. And the plain statement that an order does not always exist: “Not all classes admit a linearization”.https://docs.python.org/3/howto/mro.html
- Type Objects — PyType_ModifiedOfficial documentation. The only official sentence about cache invalidation: “Invalidate the internal lookup cache for the type and all of its subtypes. This function must be called after any manual modification of the attributes or base classes of the type”. Neither the layout of the cache nor the `tp_version_tag` field is described in the public documentation — the article says so outright.https://docs.python.org/3/c-api/type.html
- PEP 252 — Making Types Look More Like ClassesPEP. Guido van Rossum, Final, Python 2.2. The document that brought the descriptor protocol into the language, and with it the split between descriptors that only read and those that also write.https://peps.python.org/pep-0252/
- PEP 253 — Subtyping Built-in TypesPEP. Guido van Rossum, Final, Python 2.2. The other half of the same reform: metatypes, inheriting from built-in types, and where a type gets its own attribute lookup, distinct from an instance's.https://peps.python.org/pep-0253/
- cpython/object.h — tp_version_tag and tp_versions_usedCPython source code. The declarations were checked not against the repository but against the headers of the very builds the numbers come from. In 3.13.7 (`cpython/object.h:232`) the `tp_versions_used` field is declared bare, with no comment. In 3.14.7 (`cpython/object.h:241`) an explanation stands beside it: the value `_Py_ATTR_CACHE_UNUSED`, “if the attribute cache is disabled for this type (e.g. due to custom MRO entries)”, along with the constant `#define _Py_ATTR_CACHE_UNUSED (30000)`. The script `
bench/attr-lookup/mro_override.py` prints those lines from the headers of whichever interpreter it runs on. CPython tag 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Include/cpython/object.h - internal/pycore_typeobject.h — the cache itselfCPython source code. The cache layout in 3.13.7: `struct type_cache_entry` of three fields — `version`, `name`, `value` — and the table size `#define MCACHE_SIZE_EXP 12`, that is 4096 slots per interpreter. The comment above the struct: “Type attribute lookup cache: speed up attribute and method lookups, see _PyType_Lookup()”. In 3.14.7 the struct and the size are the same, but the declaration has moved to `internal/pycore_interp_structs.h`. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/internal/pycore_typeobject.h