Skip to content

78. Attribute Access Performance

Type version tags, LOAD_ATTR inline cache hits, and the attribute specialization guards for slots and descriptors.

Attribute access is one of the most frequent operations in Python. It appears whenever code reads, writes, or deletes a named field on an object.

obj.name
obj.name = value
del obj.name

At source level, attribute access looks like a simple field operation. In CPython, it is a dynamic protocol involving object types, dictionaries, descriptors, slots, method binding, inheritance, inline caches, and specialization.

A simple expression:

obj.x

may involve:

checking the object's type
looking for descriptors on the class
looking in the instance dictionary
walking base classes
calling descriptor methods
calling __getattribute__
possibly calling __getattr__
raising AttributeError if no value exists

Attribute access is therefore a major performance target.

78.1 Why Attribute Access Matters

Python code uses attributes constantly.

Common examples:

user.name
request.headers
response.status_code
path.name
module.function
self.value
cls.registry

Many object-oriented programs are dominated by attribute and method access.

For example:

for item in items:
    total += item.price

The loop body performs attribute access on every iteration. If the loop runs one million times, the item.price bytecode site becomes a hot operation.

CPython optimizes this pattern by making repeated attribute access faster when the runtime shape is stable.

78.2 Attribute Access Is Not Field Access

In C, a struct field access can compile to a fixed memory offset:

p->x

This may become a direct load from memory.

In Python:

p.x

does not mean “load from a fixed field.” It means “perform Python attribute lookup.”

That lookup must support:

instance attributes
class attributes
inherited attributes
properties
methods
slots
descriptors
custom __getattribute__
custom __getattr__
metaclasses

This flexibility is why attribute access has overhead.

78.3 The Default Lookup Path

For ordinary objects, attribute lookup begins with the type.

Conceptually, obj.x follows a path like this:

type(obj)
look for data descriptor named x on type or bases
look in obj.__dict__
look for non-data descriptor or class attribute
call descriptor if needed
return value or raise AttributeError

This order is important.

Data descriptors take priority over instance dictionaries. Non-data descriptors are checked after the instance dictionary.

This rule explains properties and methods.

78.4 Data Descriptors

A data descriptor is an object with __set__ or __delete__, usually also __get__.

Example:

class C:
    @property
    def x(self):
        return 42

The property object is a descriptor.

For:

obj.x

CPython does not simply look in obj.__dict__. It finds the property on the class and calls its descriptor logic.

Conceptually:

property.__get__(obj, type(obj))

This makes properties powerful, but it means attribute access may execute arbitrary Python code.

78.5 Instance Dictionaries

Most normal Python objects store dynamic attributes in an instance dictionary.

Example:

class User:
    pass

u = User()
u.name = "Alice"
u.age = 30

The instance dictionary is roughly:

{
    "name": "Alice",
    "age": 30,
}

Then:

u.name

usually becomes a dictionary lookup.

The dictionary key is the string "name".

This is flexible because attributes can be added at runtime:

u.email = "[email protected]"

The cost is memory and lookup overhead.

78.6 Class Dictionaries

Classes also have dictionaries.

Example:

class C:
    value = 10

    def method(self):
        return self.value

The class dictionary contains:

"value"  -> 10
"method" -> function object

When obj.method is accessed, CPython finds the function object in the class dictionary. Python functions implement the descriptor protocol, so the function binds itself to the instance and produces a method behavior.

Immediate method calls can avoid creating a temporary bound method object, but the lookup semantics remain descriptor-based.

78.7 Inheritance and the MRO

If an attribute is not found directly on the class, CPython searches base classes using the method resolution order.

Example:

class A:
    x = 1

class B(A):
    pass

b = B()
print(b.x)

Lookup checks:

B
A
object

The MRO is precomputed and stored on the type object.

Attribute access must respect this order exactly, including multiple inheritance and C3 linearization.

78.8 __getattribute__

Every attribute read calls the type’s attribute access machinery.

User classes inherit object.__getattribute__ by default.

A class can override it:

class C:
    def __getattribute__(self, name):
        print("reading", name)
        return super().__getattribute__(name)

Now every obj.x call goes through custom Python code.

This disables many simple fast-path assumptions. CPython must preserve the override semantics.

A custom __getattribute__ is powerful, but it makes every attribute read more expensive.

78.9 __getattr__

__getattr__ is different from __getattribute__.

It runs only when normal lookup fails.

Example:

class C:
    def __getattr__(self, name):
        return f"missing: {name}"

Then:

C().x

returns:

missing: x

The normal lookup path still runs first. Only after it fails does CPython call __getattr__.

This preserves common attribute lookup behavior while allowing dynamic fallback.

78.10 Slots

__slots__ gives objects fixed attribute storage.

Example:

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

Instances of Point do not need a normal per-instance dictionary unless __dict__ is included in slots.

The object layout can contain fixed slots for x and y.

Conceptually:

Point instance:
    slot 0 -> x
    slot 1 -> y

Attribute access can then become a checked offset load instead of a dictionary lookup.

Slots improve memory use and can improve attribute access speed, especially for many small objects with fixed fields.

78.11 Slot Descriptors

Slots are implemented through descriptors placed on the class.

For:

class Point:
    __slots__ = ("x", "y")

the class receives descriptor objects for x and y.

When CPython sees:

p.x

the slot descriptor knows the offset where the value is stored.

Specialized attribute access can cache that offset.

The fast path becomes:

check exact type
load slot value by offset

This is one of the fastest attribute access forms in CPython.

78.12 Method Access

A method access:

obj.method

usually returns a bound method object.

The bound method stores:

underlying function
bound instance

Example:

m = obj.method
m()

Here m must exist as a first-class object.

But for immediate calls:

obj.method()

CPython can often avoid creating the bound method object. It can keep the function and instance separately and call the function with self inserted.

This optimization is critical because method calls are common.

78.13 Module Attributes

Module attributes are stored in the module dictionary.

Example:

import math
math.sqrt(9)

The math.sqrt lookup is an attribute access on the module object.

Conceptually:

module.__dict__["sqrt"]

Module attribute access is common in code that calls imported functions.

CPython can specialize module attribute loads when the module dictionary remains stable.

78.14 Attribute Writes

Attribute writes use related but separate machinery.

obj.x = value

The assignment may:

call a data descriptor's __set__
write into a slot
write into the instance dictionary
fail if the object is read-only
fail if slots reject the name

For ordinary objects, the write updates obj.__dict__.

For slot objects, the write stores into a fixed slot.

For properties with setters, the write calls descriptor code.

78.15 Attribute Deletion

Deletion also follows object protocol rules.

del obj.x

This may:

call a descriptor's __delete__
delete from the instance dictionary
clear a slot
raise AttributeError

Deletion can invalidate inline caches because it mutates object or class state.

78.16 Type Version Tags

CPython uses type version tags to validate attribute caches.

If a class dictionary or relevant type state changes, the type version changes.

An inline cache can store:

expected type pointer
expected type version
cached lookup result or offset

Fast path:

if type(obj) == cached_type
and cached_type version is unchanged:
    use cached lookup
else:
    fall back to generic lookup

This makes repeated attribute access cheap while preserving dynamic behavior.

78.17 Dictionary Version Tags

Instance dictionaries and module dictionaries also have version tags.

A global or module attribute cache may store:

expected dictionary version
cached value

When the dictionary mutates, the version changes.

This invalidates the cached assumption without scanning the dictionary.

Version tags are the foundation of many fast attribute and global lookup paths.

78.18 Inline Caches for LOAD_ATTR

The bytecode instruction for reading an attribute is commonly represented as LOAD_ATTR.

For:

obj.x

the interpreter executes a LOAD_ATTR-family instruction.

Modern CPython can specialize this instruction based on observed runtime behavior.

Possible specialized paths include:

instance dictionary value
slot value
module attribute
class attribute
method load
descriptor path

The cache is stored near the instruction, so the lookup site has local remembered state.

78.19 Cache Hit Path

A fast attribute cache hit is deliberately small.

Example for a slot-like access:

load obj
check Py_TYPE(obj) == cached_type
check type version unchanged
read value from cached offset
push result

This avoids:

hashing attribute name
searching dictionaries
walking MRO
running generic descriptor logic

The validation still protects semantics.

78.20 Cache Miss Path

A cache miss falls back to generic lookup.

Misses happen when:

object type differs
class dictionary changed
instance dictionary layout changed
attribute was deleted
descriptor changed
custom lookup is involved

The miss path may also update the cache or cause deoptimization.

The key invariant:

cache miss changes performance, not correctness

78.21 Attribute Access and Specialization

The adaptive interpreter observes each attribute access site.

Example:

def get_x(obj):
    return obj.x

At first, the LOAD_ATTR is generic or adaptive.

After repeated calls with the same shape:

get_x(Point(1, 2))
get_x(Point(3, 4))
get_x(Point(5, 6))

CPython may specialize the instruction.

The specialization belongs to the bytecode site, not to the attribute name globally.

Another function reading .x may specialize differently.

78.22 Monomorphic Attribute Sites

A monomorphic attribute site sees one dominant type.

Example:

for p in points:
    total += p.x

If every p is a Point, this site is monomorphic.

Monomorphic sites specialize well.

The cache only needs to remember one type and one lookup path.

78.23 Polymorphic Attribute Sites

A polymorphic site sees multiple types.

Example:

def get_name(obj):
    return obj.name

called with:

User
Team
Project
Organization

A simple inline cache may miss frequently.

CPython can still optimize some cases, but very unstable sites tend to remain generic.

The performance cost comes from repeated failed assumptions.

78.24 Megamorphic Sites

A megamorphic site sees many unrelated shapes.

Example:

for obj in mixed_objects:
    handle(obj.value)

where mixed_objects contains dozens of unrelated types.

In this case, specialization provides limited value.

The interpreter may back off to avoid repeated specialization attempts.

Megamorphic sites are common in plugin systems, serialization frameworks, ORMs, and dynamic dispatch-heavy code.

78.25 Attribute Access and __dict__

Objects with normal instance dictionaries are flexible.

obj.any_new_name = value

This flexibility has costs:

per-instance dictionary memory
hash table lookup
dictionary version management
cache invalidation

For many objects with the same fields, split dictionaries reduce memory by sharing keys.

For fixed-layout objects, slots can reduce memory further.

78.26 Split Instance Dictionaries

For many user-defined classes, CPython uses split dictionaries.

Instances of the same class can share a key table.

Example:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

Many instances share keys:

name
age

Each instance stores only values:

user1 values: ["Alice", 30]
user2 values: ["Bob", 40]

This improves memory use and can help attribute access locality.

78.27 Attribute Access and Globals

Accessing a global variable:

config

uses name lookup, not attribute lookup.

But accessing a module attribute:

settings.config

uses attribute lookup on the module object.

Both are dictionary-backed and cacheable.

This is why imports and modules interact with lookup performance.

78.28 Attribute Access and Properties

Properties look like fields but behave like method calls.

class C:
    @property
    def value(self):
        return compute_value()

Access:

obj.value

calls:

property.__get__

which calls the getter.

This means property access can be arbitrarily expensive.

From a performance perspective, a property should be treated like a function call with attribute syntax.

78.29 Attribute Access and cached_property

functools.cached_property stores a computed value after the first lookup.

Conceptually:

first access:
    compute value
    store result in instance dict

later access:
    read stored value from instance dict

This changes the performance profile from descriptor execution to dictionary lookup after the first access.

It is useful for expensive derived values on long-lived objects.

78.30 Attribute Access and Metaclasses

Class attribute access can involve metaclasses.

Example:

class Meta(type):
    def __getattribute__(cls, name):
        return super().__getattribute__(name)

class C(metaclass=Meta):
    x = 1

Access:

C.x

uses lookup rules involving the metaclass.

Metaclasses make class objects behave dynamically. They also complicate attribute caching.

78.31 Attribute Access and Modules With __getattr__

Modules can define __getattr__.

Example:

# module file
def __getattr__(name):
    ...

This allows lazy or dynamic module attributes.

But it means module attribute access may execute custom code on misses.

CPython must preserve this behavior, so caches must fall back correctly.

78.32 Attribute Access and C Extension Types

C extension types can define custom attribute behavior through type slots.

Important hooks include:

tp_getattro
tp_setattro
tp_members
tp_getset
method tables
descriptors

A C extension type can provide highly optimized attribute access or highly dynamic custom access.

Built-in types often use C-level layouts and descriptors for speed.

78.33 Attribute Access and Built-in Types

Built-in types such as list, dict, str, and int do not usually have per-instance dictionaries.

Their attributes and methods are defined at the type level.

Example:

items.append
text.upper
mapping.get

Method descriptors on built-in types are optimized.

An immediate method call like:

items.append(x)

can use specialized method lookup and call paths.

78.34 Attribute Access and Memory Locality

Performance depends on locality.

A generic attribute lookup may touch:

object header
type object
type dictionary
instance dictionary
descriptor object
base class dictionaries
inline cache entries

Each pointer chase can miss CPU caches.

Fast paths reduce pointer chasing by using cached offsets, versions, and direct references.

This is one reason slots and stable layouts can be faster.

78.35 Practical Performance Guidelines

Attribute access optimization suggests several practical rules.

PatternReason
Prefer stable object shapes in hot loopsHelps inline caches specialize
Use local variables for repeated expensive lookupsAvoid repeated lookup work
Treat properties as function callsThey may execute arbitrary code
Consider __slots__ for many small fixed-shape objectsReduces memory and can speed access
Avoid custom __getattribute__ unless necessaryIt intercepts every attribute read
Avoid megamorphic hot sites when performance mattersCache misses reduce specialization benefit

Example:

append = items.append
for x in source:
    append(x)

This can avoid repeated method lookup in some cases. Modern CPython already optimizes many immediate method calls, so this should be measured before use.

78.36 Measuring Attribute Access

Use measurement rather than intuition.

Simple tools:

import timeit

timeit.timeit("obj.x", setup="class C: pass\nobj=C(); obj.x=1")

For bytecode:

import dis

def f(obj):
    return obj.x

dis.dis(f, adaptive=True, show_caches=True)

This can show specialized forms and cache entries after warmup in supported Python versions.

78.37 Reading Attribute Access Source

Important CPython source areas:

FilePurpose
Objects/object.cGeneric attribute access helpers
Objects/typeobject.cType lookup, descriptors, MRO behavior
Objects/descrobject.cDescriptor implementations
Objects/dictobject.cInstance and class dictionary behavior
Python/ceval.cBytecode execution for attribute operations
Python/specialize.cAttribute specialization logic

When reading the source, separate three levels:

semantic lookup rules
generic C implementation
specialized interpreter fast paths

78.38 Mental Model

A useful model:

Attribute access is dynamic lookup with cached fast paths.

The generic path preserves Python’s full object model.

The fast path exploits stable runtime shapes.

first executions:
    generic lookup

hot stable site:
    cached type and layout checks

cache hit:
    direct load or descriptor-aware fast path

cache miss:
    fallback to generic lookup

78.39 Chapter Summary

Attribute access performance is central to CPython performance.

The operation:

obj.x

may involve dictionaries, descriptors, slots, inheritance, custom hooks, modules, metaclasses, and C extension slots.

Modern CPython improves common cases with:

inline caches
type version tags
dictionary version tags
specialized LOAD_ATTR forms
method-call fast paths
slot offset caching
split dictionaries

Fast attribute access depends on stable object shapes and cheap validation. Dynamic features remain fully supported, but they may force the interpreter back to the generic path.