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

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

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:

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

The 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 hasdel obj.xobj.x with a value in __dict__
__get__goes to the dictionaryfrom the dictionary
__get__ + __set__AttributeErrorfrom the descriptor
__get__ + __delete__to the descriptorfrom the descriptor
__delete__ onlyto the descriptorfrom 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:

PYTHON
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")  # AttributeError

The 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 classwhat actually sits there__get__kind
an ordinary methodfunctionyesnon-data
@propertypropertyyesdata
@staticmethodstaticmethodyesnon-data
@classmethodclassmethodyesnon-data
__slots__member_descriptoryesdata
@functools.cached_propertycached_propertyyesnon-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:

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

PYTHON
class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class Impossible(A, B): pass
TypeError: 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:

typedepth__mro__
int, str, list, dict2the type and object
bool3bool, int, object
Exception3Exception, BaseException, object
ValueError4plus Exception and BaseException
io.StringIO4StringIO, _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:

C
// 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 12

4096 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__ depthcache intact, 3.13.7cache invalidated, 3.13.7
210.8 ns18.7 ns
610.9 ns29.2 ns
2111.0 ns87.4 ns
5111.9 ns180.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:

C
    /* 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:

PYTHON
class OwnMRO(type):
    def mro(cls):
        return super().mro()   # the order does NOT change

Measured on 3.14.7, three configurations at three chain depths:

configurationdepth 2depth 21depth 51
ordinary type6.5 ns6.4 ns6.4 ns
metaclass without mro()6.4 ns6.4 ns6.4 ns
metaclass with its own mro()15.3 ns63.8 ns118.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 type118.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:

PYTHON
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 measured3.13.7
obj.m() — take and call in one go16.3 ns
obj.m — take only24.6 ns
bound() — call one already taken18.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 measured3.13.7
direct.m(), with Base.m(self) inside31.5 ns
mid.m(), with super().m() inside43.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

VersionChangeWhat it means for code
3.12The 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.13The 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.14The 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:

Common misconceptions

Claim

Deep inheritance slows attribute access down

Actually

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.

Claim

__getattr__ is called when an attribute is not found

Actually

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.

Claim

In the diamond class D(L, R) the walk goes down the whole L branch first

Actually

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.

Claim

cached_property is a separate caching mechanism

Actually

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.

Claim

Hoisting obj.method into a variable before a loop is an optimisation

Actually

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.

Claim

The layout of the type cache is documented

Actually

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.

Claim

A metaclass by itself slows attribute access down

Actually

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

Question 1 of 5

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

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