Skip to content

33. Attribute Lookup

LOAD_ATTR bytecode, __getattribute__ dispatch, the descriptor protocol, and type version tag caching.

Attribute lookup is the runtime process used to evaluate expressions such as:

obj.name

It is one of the most important mechanisms in CPython. Method calls, properties, descriptors, inheritance, modules, classes, slots, special methods, and many object protocols depend on it.

A simple attribute expression can trigger a large amount of machinery:

find the object's type
search the type and its base classes
handle descriptors
check the instance dictionary
call custom attribute hooks
return a value or raise AttributeError

Attribute lookup is dynamic. The result can depend on the runtime object, its class, its base classes, its instance dictionary, descriptors, metaclasses, and user-defined hooks.

33.1 Basic Attribute Access

The expression:

obj.x

asks CPython to look up the attribute named "x" on obj.

If found, the lookup returns a Python object.

If missing, it raises AttributeError.

Example:

class C:
    pass

obj = C()
obj.x = 10

print(obj.x)

The assignment stores x in the instance dictionary:

obj.__dict__["x"] = 10

The access finds it there.

33.2 Attribute Lookup Is Not Dictionary Lookup Only

For simple objects, attribute access may look like dictionary lookup:

obj.__dict__["x"]

But full attribute lookup is more complex.

This expression:

obj.x

may involve:

data descriptors
instance dictionary
non-data descriptors
class attributes
base classes
__getattribute__
__getattr__
slots
properties
metaclass behavior

So this equivalence is incomplete:

obj.x == obj.__dict__["x"]

It only holds in simple cases.

33.3 Instance Dictionaries

Most normal Python objects have an instance dictionary.

class User:
    pass

u = User()
u.name = "Ada"
u.age = 37

print(u.__dict__)

Output:

{'name': 'Ada', 'age': 37}

Instance attributes are stored in this dictionary unless the class uses slots or a custom layout.

For:

u.name

CPython can find "name" in u.__dict__.

But the instance dictionary is not always checked first. Data descriptors on the class have priority.

33.4 Class Attributes

Attributes can live on the class.

class User:
    kind = "human"

u = User()
print(u.kind)

Here, kind is not in u.__dict__. It is in User.__dict__.

Conceptually:

u.__dict__ does not contain "kind"
User.__dict__ contains "kind"
return "human"

Class attributes are shared through the class:

a = User()
b = User()

print(a.kind)
print(b.kind)

Both instances see the same class attribute unless an instance shadows it.

33.5 Instance Attribute Shadowing

An instance attribute can shadow a class attribute when the class attribute is not a data descriptor.

class User:
    kind = "human"

u = User()
u.kind = "admin"

print(u.kind)
print(User.kind)

Output:

admin
human

Now:

u.__dict__["kind"] = "admin"
User.__dict__["kind"] = "human"

The instance attribute wins for u.kind.

This is why class attributes should be used carefully for mutable values:

class Bag:
    items = []

a = Bag()
b = Bag()

a.items.append("x")
print(b.items)

Both instances see the same list unless items is overridden on the instance.

33.6 Attribute Lookup Order

For normal instance attribute access, the lookup order is roughly:

1. Call type(obj).__getattribute__(obj, name)
2. Search the class and base classes for name
3. If a data descriptor is found, call descriptor.__get__
4. Otherwise, check obj.__dict__
5. If found in obj.__dict__, return that value
6. Otherwise, if a non-data descriptor was found, call descriptor.__get__
7. Otherwise, if a class attribute was found, return it
8. Otherwise, call __getattr__ if defined
9. Otherwise, raise AttributeError

The most important priority rule is:

data descriptor
    before instance dictionary

instance dictionary
    before non-data descriptor

non-data descriptor or class attribute
    after instance dictionary

This order explains properties, methods, slots, and shadowing.

33.7 Descriptors

A descriptor is an object that controls attribute access through one or more special methods:

__get__
__set__
__delete__

Descriptors are stored on classes.

Example:

class Descriptor:
    def __get__(self, obj, cls):
        return "computed"

class C:
    x = Descriptor()

obj = C()
print(obj.x)

Accessing obj.x calls:

C.__dict__["x"].__get__(obj, C)

Descriptors allow classes to customize what attribute access means.

They power:

methods
staticmethod
classmethod
property
slots
many built-in attributes
ORM fields
validation systems
lazy attributes

33.8 Data Descriptors

A data descriptor defines __set__ or __delete__, usually with __get__.

class DataDescriptor:
    def __get__(self, obj, cls):
        return "from descriptor"

    def __set__(self, obj, value):
        print("set", value)

class C:
    x = DataDescriptor()

obj = C()
obj.__dict__["x"] = "from dict"

print(obj.x)

Output:

from descriptor

The descriptor wins over the instance dictionary because it is a data descriptor.

This is how property works.

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

obj = C()
print(obj.x)

The property object is a data descriptor.

33.9 Non-Data Descriptors

A non-data descriptor defines __get__ but not __set__ or __delete__.

Normal Python functions stored on classes are non-data descriptors.

class C:
    def f(self):
        return "method"

obj = C()
print(obj.f())

The function descriptor binds obj as self.

But because it is non-data, an instance attribute can shadow it:

obj.f = lambda: "instance function"
print(obj.f())

Now the instance dictionary wins.

This is why methods can be replaced per instance.

33.10 Properties

A property is a descriptor that calls functions during attribute access.

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius * self.radius

c = Circle(10)
print(c.area)

The expression:

c.area

calls the property getter.

There is no explicit () in the source because the call is hidden inside descriptor access.

A setter adds assignment behavior:

class User:
    def __init__(self):
        self._name = ""

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value.strip()

Assignment:

u.name = " Ada "

calls the property setter.

33.11 Methods as Descriptors

Functions stored on classes are descriptors.

class C:
    def f(self):
        return 1

obj = C()
m = obj.f

The lookup:

obj.f

calls the function object’s descriptor logic and produces a bound method.

Conceptually:

C.__dict__["f"].__get__(obj, C)
    -> bound method

The bound method stores:

function = C.f
self = obj

Then:

obj.f()

calls the function with self automatically inserted.

33.12 staticmethod and classmethod

staticmethod and classmethod are descriptor wrappers.

A static method disables binding:

class Math:
    @staticmethod
    def add(a, b):
        return a + b

Math.add(1, 2)
Math().add(1, 2)

No self or cls is inserted.

A class method binds the class:

class User:
    @classmethod
    def make(cls):
        return cls()

Calling:

User.make()

passes User as the first argument.

Calling through an instance also passes the class:

User().make()

33.13 Slots

__slots__ changes instance attribute storage.

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

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

Instances of Point usually do not have a normal __dict__ unless explicitly requested.

p = Point(1, 2)
print(p.x)

Slot attributes are implemented with descriptors stored on the class.

Conceptually:

Point.__dict__["x"] = slot descriptor
Point.__dict__["y"] = slot descriptor

Accessing p.x invokes the slot descriptor, which reads from a fixed location in the object layout.

Slots can reduce memory use and speed up some attribute access patterns, but they also limit dynamic instance attributes.

33.14 Missing Attributes

If normal lookup fails, Python may call __getattr__.

class Dynamic:
    def __getattr__(self, name):
        if name == "answer":
            return 42
        raise AttributeError(name)

d = Dynamic()
print(d.answer)

__getattr__ is called only after the normal lookup path fails.

This is useful for:

lazy loading
proxy objects
RPC clients
compatibility layers
dynamic APIs
mock objects

If __getattr__ also fails, it should raise AttributeError.

33.15 __getattribute__

__getattribute__ intercepts all normal attribute access.

class Traced:
    def __getattribute__(self, name):
        print("lookup", name)
        return super().__getattribute__(name)

    def f(self):
        return 1

obj = Traced()
obj.f()

Every access goes through __getattribute__, including method lookup.

A custom implementation must avoid infinite recursion:

class Bad:
    def __getattribute__(self, name):
        return self.__dict__[name]

Accessing self.__dict__ calls __getattribute__ again.

Use object.__getattribute__ or super().__getattribute__:

class Good:
    def __getattribute__(self, name):
        return object.__getattribute__(self, name)

33.16 Attribute Assignment

Assignment:

obj.x = value

uses attribute setting logic.

The rough order is:

1. Call type(obj).__setattr__(obj, name, value)
2. If a data descriptor with __set__ exists, use it
3. Otherwise, store into obj.__dict__ if available
4. Otherwise, use slot storage if applicable
5. Otherwise, raise AttributeError

A custom __setattr__ can intercept assignment:

class C:
    def __setattr__(self, name, value):
        print("set", name, value)
        super().__setattr__(name, value)

obj = C()
obj.x = 10

As with __getattribute__, careless code can recurse.

33.17 Attribute Deletion

Deletion:

del obj.x

uses deletion logic.

It may call:

type(obj).__delattr__
descriptor.__delete__
remove from instance dictionary
clear slot value

Example descriptor:

class D:
    def __delete__(self, obj):
        print("delete")

class C:
    x = D()

obj = C()
del obj.x

Deletion is part of the same descriptor and attribute protocol family.

33.18 Class Attribute Lookup

Classes are objects too.

class C:
    x = 10

print(C.x)

Looking up C.x is attribute access on a class object.

The type of C is usually type, so class attribute lookup is controlled by metaclass behavior.

Conceptually:

object = C
type(object) = type
lookup attribute "x"

This is why metaclasses can customize class-level attribute access.

33.19 Metaclass Attribute Lookup

A metaclass can define attributes visible on classes.

class Meta(type):
    def meta_method(cls):
        return "meta"

class C(metaclass=Meta):
    pass

print(C.meta_method())

The method is found on the metaclass and bound to the class object.

Class attribute lookup involves:

attributes on the class itself
descriptors in the metaclass
metaclass MRO

Metaclass lookup is separate from instance lookup, but it uses the same general object model.

33.20 Module Attribute Lookup

Modules have attribute dictionaries.

import math
print(math.pi)

This is roughly:

math.__dict__["pi"]

Modules can also define __getattr__ for missing attributes.

# module.py
def __getattr__(name):
    if name == "lazy":
        return load_lazy()
    raise AttributeError(name)

Then:

module.lazy

can be computed dynamically.

This feature is often used for lazy imports and compatibility shims.

33.21 Attribute Lookup and Inheritance

For instances, class lookup follows the method resolution order.

class A:
    x = "A"

class B(A):
    pass

obj = B()
print(obj.x)

Lookup searches:

B
A
object

The order is stored in:

print(B.__mro__)

Multiple inheritance uses C3 linearization to compute a consistent MRO.

class A: pass
class B: pass
class C(A, B): pass

print(C.__mro__)

Attribute lookup relies on this order.

33.22 Attribute Lookup and super

super() changes where lookup begins.

class Base:
    def f(self):
        return "base"

class Child(Base):
    def f(self):
        return super().f()

Inside Child.f, super().f searches after Child in the MRO, then binds the found method to the original instance.

Conceptually:

MRO: Child, Base, object
super from Child
search Base, then object
bind result to self

super() is not simple parent-class access. It is MRO-relative descriptor lookup.

33.23 Special Method Lookup

Special methods are often looked up on the type, not through normal instance attribute lookup.

Example:

len(obj)

uses the type’s length slot. It does not simply perform:

obj.__len__()

This distinction matters:

class C:
    def __len__(self):
        return 10

obj = C()
obj.__len__ = lambda: 20

print(obj.__len__())
print(len(obj))

The explicit call may find the instance attribute. The len() operation uses special method lookup through the type.

Special method lookup is designed for speed and consistency of the object protocol.

33.24 Attribute Lookup in Bytecode

Attribute access compiles to bytecode instructions such as:

LOAD_ATTR
STORE_ATTR
DELETE_ATTR
LOAD_METHOD

For:

value = obj.x

conceptual bytecode:

LOAD_FAST obj
LOAD_ATTR x
STORE_FAST value

For:

obj.x = value

conceptual bytecode:

LOAD_FAST value
LOAD_FAST obj
STORE_ATTR x

For method calls:

obj.f(arg)

CPython may use a method-specific load instruction to optimize binding and calling.

33.25 Attribute Lookup Can Execute Code

Attribute lookup is not always passive.

This expression:

obj.x

can call user code through:

__getattribute__
descriptor __get__
property getter
__getattr__
metaclass hooks
module __getattr__

Therefore attribute access can:

raise exceptions
mutate state
perform I/O
allocate objects
return different values each time
call arbitrary Python code

Example:

class C:
    @property
    def x(self):
        print("computed")
        return 10

obj = C()
obj.x
obj.x

The getter runs each time.

33.26 Attribute Lookup and Exceptions

If an attribute is missing, Python raises AttributeError.

obj.missing

But attribute lookup can raise other exceptions too.

Example:

class C:
    @property
    def x(self):
        raise RuntimeError("failed")

obj = C()
obj.x

This raises RuntimeError, not AttributeError.

Only missing attributes should generally raise AttributeError. Tools such as hasattr depend on this convention.

hasattr(obj, "x")

works by attempting lookup and catching AttributeError.

33.27 Attribute Lookup and hasattr

hasattr(obj, name) calls attribute lookup.

hasattr(obj, "x")

is roughly:

try:
    getattr(obj, "x")
except AttributeError:
    return False
else:
    return True

This means hasattr can execute user code.

If a property getter raises RuntimeError, hasattr does not treat that as a missing attribute.

class C:
    @property
    def x(self):
        raise RuntimeError("boom")

hasattr(C(), "x")

The RuntimeError propagates.

33.28 Attribute Lookup and getattr

getattr performs dynamic attribute lookup.

getattr(obj, "name")

is equivalent to:

obj.name

when the attribute name is known statically.

It also supports a default:

getattr(obj, "missing", default)

This returns default only if lookup raises AttributeError.

It does not suppress arbitrary exceptions from descriptors or custom lookup hooks.

33.29 Attribute Lookup and setattr

setattr performs dynamic attribute assignment.

setattr(obj, "x", 10)

is equivalent to:

obj.x = 10

for a static name.

It still respects:

__setattr__
data descriptors
slots
read-only attributes

So setattr is not a raw dictionary write.

33.30 Attribute Lookup and delattr

delattr performs dynamic attribute deletion.

delattr(obj, "x")

is equivalent to:

del obj.x

It still respects:

__delattr__
descriptor __delete__
slot deletion
instance dictionary deletion

33.31 Attribute Lookup and Inline Caches

Attribute lookup is frequent, so CPython optimizes it.

A repeated access:

for obj in objects:
    total += obj.value

may hit the same attribute layout many times.

CPython can attach inline cache data to the bytecode instruction. The cache may store facts such as:

expected receiver type
type version tag
dictionary version
descriptor result
slot offset
instance dictionary offset

On later executions:

if guards still hold:
    use fast path
else:
    fall back to generic lookup

This preserves dynamic semantics while accelerating stable cases.

33.32 Cache Invalidation

Python allows class mutation.

class C:
    x = 1

obj = C()
print(obj.x)

C.x = 2
print(obj.x)

The second lookup must see 2.

Therefore an attribute cache cannot blindly reuse old results. It must guard against changes.

Typical guards may involve:

type identity
type version
dictionary version
descriptor kind
instance layout

When assumptions fail, CPython falls back to generic lookup and may update the cache.

33.33 Instance Layout Stability

Attribute caches work best when object layouts are stable.

Example:

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

points = [Point(i, i + 1) for i in range(1000)]

for p in points:
    p.x

Each p has the same type and likely similar attribute layout. This is a good case for specialization.

Dynamic changes can reduce cache effectiveness:

p.z = 10
del p.x
Point.x = property(...)

The interpreter must preserve correctness first.

33.34 Attribute Lookup and Dictionaries

Instance dictionaries are highly optimized.

CPython dictionaries are central to object namespaces:

module namespace
class namespace
instance namespace
globals
builtins

For many objects, attribute lookup ultimately becomes dictionary lookup plus descriptor handling.

Attribute caches often depend on dictionary versioning so the interpreter can tell whether a namespace changed.

33.35 Attribute Lookup and Modules

Module globals are also dictionary-based.

import math
math.sqrt

The module object stores attributes in its dictionary.

Module lookup is simpler than instance descriptor lookup in many cases, but modules can customize missing attribute behavior through __getattr__.

Imports also bind module attributes:

import package.submodule

After import, the package may have a submodule attribute.

33.36 Attribute Lookup and Builtins

Built-in types often use specialized internal layouts and descriptors.

Example:

list.append
dict.get
str.upper
int.bit_length

These attributes are typically method descriptors implemented in C.

Access through an instance:

[].append

returns a bound built-in method object.

Immediate calls may use optimized method-call paths.

33.37 Attribute Lookup and Memory Management

Attribute lookup manipulates references.

When an attribute is returned, CPython must ensure the result stays alive.

If lookup creates a bound method, property result, or descriptor result, reference ownership must be handled correctly.

Temporary objects may be created during lookup:

bound methods
property return values
descriptor return values
exception objects
strings or proxy objects in custom hooks

Failure paths must clean them up.

Because attribute lookup can call Python code, reference safety is critical.

33.38 Attribute Lookup and Reentrancy

Attribute lookup can reenter Python.

Example:

class C:
    def __getattribute__(self, name):
        return compute_value(name)

The call to compute_value may execute arbitrary Python code.

This means during one attribute lookup, Python code can:

mutate the object
mutate the class
change descriptors
trigger garbage collection
raise exceptions
call back into the same object

The lookup implementation must tolerate this.

33.39 Attribute Lookup and Proxies

Proxy objects often implement custom attribute lookup.

class Proxy:
    def __init__(self, target):
        self._target = target

    def __getattr__(self, name):
        return getattr(self._target, name)

Then:

proxy.x

delegates to:

target.x

A robust proxy must handle special methods carefully because implicit special method lookup often bypasses normal instance attribute lookup.

For example, implementing __getattr__ alone may not make len(proxy) delegate to len(target).

33.40 Attribute Lookup and ORMs

Many ORMs use descriptors.

Example shape:

class Field:
    def __get__(self, obj, cls):
        return obj._data[self.name]

    def __set__(self, obj, value):
        obj._data[self.name] = value

class User:
    name = Field()

Then:

user.name

does not read a plain attribute. It calls Field.__get__.

This allows libraries to implement:

validation
lazy loading
database column mapping
change tracking
computed fields
relationship loading

Descriptors turn attribute syntax into programmable access.

33.41 Attribute Lookup and Dataclasses

Dataclasses do not fundamentally change attribute lookup.

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

Instances usually store fields in the instance dictionary unless slots are enabled.

p = Point(1, 2)
print(p.x)

This is ordinary instance attribute lookup.

With slots:

@dataclass(slots=True)
class Point:
    x: int
    y: int

field storage uses slots rather than a normal instance dictionary.

33.42 Attribute Lookup and Type Objects

Types are objects, and they also have attributes.

int.bit_length
str.upper
dict.items

These are attributes on type objects.

When looking up attributes on instances, the instance’s type controls lookup.

When looking up attributes on classes, the metaclass controls lookup.

This recursive object model is central to Python:

object has type
type is also an object
type has metaclass
metaclass controls class attribute lookup

33.43 Attribute Lookup and Performance

Attribute access has more overhead than local variable access.

Compare:

x

inside a function, where x is local:

LOAD_FAST

with:

obj.x

which requires:

LOAD_FAST obj
LOAD_ATTR x

LOAD_ATTR may involve descriptor logic, dictionary lookup, cache checks, type version checks, and error handling.

This is why hot loops sometimes benefit from local binding:

append = xs.append
for item in items:
    append(item)

The benefit depends on Python version and specialization. Modern CPython optimizes method calls aggressively, so manual local binding is not always faster.

33.44 Attribute Lookup and Errors in Hot Paths

Attribute lookup failure is relatively expensive because it constructs an exception.

Example:

try:
    value = obj.missing
except AttributeError:
    value = default

For frequent misses, using a dictionary or explicit sentinel may be faster.

But the best design depends on semantics. Attribute lookup failure is correct and idiomatic when absence is exceptional or when interacting with dynamic APIs.

33.45 Inspecting Attribute Lookup

Use vars to inspect instance dictionaries:

class C:
    pass

obj = C()
obj.x = 1

print(vars(obj))

Use class dictionaries:

print(C.__dict__)

Use MRO:

print(C.__mro__)

Use inspect.getattr_static to inspect attributes without triggering normal dynamic lookup in many cases:

import inspect

inspect.getattr_static(obj, "x")

This is useful when descriptors or __getattr__ would execute code.

33.46 A Minimal Attribute Lookup Model

A simplified lookup model:

def lookup(obj, name):
    cls = type(obj)

    class_attr = find_in_mro(cls, name)

    if is_data_descriptor(class_attr):
        return class_attr.__get__(obj, cls)

    if hasattr(obj, "__dict__") and name in obj.__dict__:
        return obj.__dict__[name]

    if has_get(class_attr):
        return class_attr.__get__(obj, cls)

    if class_attr is not missing:
        return class_attr

    getattr_hook = find_in_mro(cls, "__getattr__")
    if getattr_hook is not missing:
        return getattr_hook(obj, name)

    raise AttributeError(name)

This omits important real details:

custom __getattribute__
metaclasses
slots internals
C-level fast paths
reference counts
inline caches
error handling
module lookup
special method lookup

But it captures the normal descriptor priority order.

33.47 Common Misunderstandings

MisunderstandingCorrect model
obj.x always reads obj.__dict__["x"]Data descriptors, slots, class attributes, and hooks may intervene
Methods are stored on each instanceNormal methods are stored on the class and bound during lookup
Properties are fieldsProperties are descriptors that call functions
__getattr__ handles every lookupIt only runs after normal lookup fails
__getattribute__ handles missing attributes onlyIt handles all normal attribute access
Instance attributes always beat class attributesData descriptors beat instance attributes
Special methods always use ordinary lookupMany are looked up through type slots
Attribute access is side-effect freeIt can execute arbitrary Python code

33.48 Reading Strategy

To study attribute lookup, build examples in this order:

class C:
    x = 1

Then add:

obj.x = 2

Then add a method:

def f(self): ...

Then replace it with:

@property
def x(self): ...

Then add:

__getattr__
__getattribute__
__slots__
staticmethod
classmethod
multiple inheritance
metaclass

For each step, inspect:

vars(obj)
C.__dict__
C.__mro__
type(obj)

And disassemble access sites:

import dis

def read(obj):
    return obj.x

dis.dis(read)

This builds a practical map from syntax to object model behavior.

33.49 Chapter Summary

Attribute lookup is the mechanism behind obj.name. It is a dynamic protocol involving object type, class dictionaries, instance dictionaries, descriptors, inheritance, custom hooks, slots, modules, metaclasses, and inline caches.

The core lookup priority for normal instance attributes is:

data descriptor
instance dictionary
non-data descriptor
class attribute
__getattr__
AttributeError

This order explains methods, properties, shadowing, slots, and many framework patterns.

CPython optimizes attribute lookup heavily with specialized bytecode paths and inline caches, but it must preserve Python’s dynamic semantics. Classes can change, instances can change, descriptors can run code, and custom hooks can override the entire process.