Attribute Lookup Chain — Deep Dive

The C-Level Implementation

When you write obj.attr, CPython calls type(obj).__getattribute__(obj, 'attr'). The default implementation in object.__getattribute__ (defined in Objects/object.c as PyObject_GenericGetAttr) follows this exact algorithm:

# Pseudocode of object.__getattribute__
def __getattribute__(self, name):
    # Step 1: Search the class hierarchy for a descriptor
    for cls in type(self).__mro__:
        if name in cls.__dict__:
            descriptor = cls.__dict__[name]
            # Check if it's a data descriptor
            desc_get = getattr(type(descriptor), '__get__', None)
            desc_set = getattr(type(descriptor), '__set__', None)
            if desc_get and (desc_set or hasattr(type(descriptor), '__delete__')):
                # Data descriptor — call __get__ immediately
                return desc_get(descriptor, self, type(self))
            break  # Found something, but not a data descriptor

    # Step 2: Check instance __dict__
    if hasattr(self, '__dict__') and name in self.__dict__:
        return self.__dict__[name]

    # Step 3: Non-data descriptor or class attribute
    if descriptor is not None:
        if desc_get:
            return desc_get(descriptor, self, type(self))
        return descriptor

    # Step 4: Nothing found
    raise AttributeError(name)

After AttributeError, Python checks for __getattr__ on the class (this happens one level up in the tp_getattro slot).

Data Descriptors vs. Non-Data Descriptors: Why It Matters

The distinction between data and non-data descriptors is the key design decision in Python’s attribute system.

Functions Are Non-Data Descriptors

Every function object has a __get__ method (making it a descriptor) but no __set__. This means instance attributes can shadow methods:

class MyClass:
    def method(self):
        return "class method"

obj = MyClass()
obj.method = lambda: "instance override"
obj.method()  # Returns "instance override"

This works because method is a non-data descriptor, so obj.__dict__['method'] takes priority.

Properties Are Data Descriptors

class MyClass:
    @property
    def value(self):
        return 42

obj = MyClass()
obj.__dict__['value'] = 99  # Sneaking a value into the instance dict
print(obj.value)  # Still 42 — property (data descriptor) wins

The property’s __get__ takes priority over obj.__dict__ because it defines both __get__ and __set__.

Making a Descriptor “Data” vs. “Non-Data”

class NonDataDescriptor:
    """Only __get__ — instance dict can override."""
    def __get__(self, obj, objtype=None):
        return "from descriptor"

class DataDescriptor:
    """Has __get__ and __set__ — beats instance dict."""
    def __get__(self, obj, objtype=None):
        return "from descriptor"
    def __set__(self, obj, value):
        pass  # Even a no-op __set__ makes it a data descriptor!

A __set__ that does nothing still makes the descriptor “data” — priority changes based on the existence of the method, not its behavior.

__slots__ and the Lookup Chain

__slots__ creates data descriptors (specifically, member_descriptor objects) at the class level:

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

# These are data descriptors on Point
print(type(Point.x))  # <class 'member_descriptor'>
print(hasattr(Point.x, '__set__'))  # True

Because slot descriptors are data descriptors, they take priority over instance __dict__. But with __slots__, there usually is no instance __dict__ (unless you also include '__dict__' in __slots__).

__getattribute__ vs. __getattr__

These two hooks serve different purposes:

HookWhen It RunsRisk Level
__getattribute__On every attribute accessHigh — easy to cause infinite recursion
__getattr__Only when normal lookup failsLow — clean fallback mechanism

Overriding __getattribute__

class Traced:
    def __getattribute__(self, name):
        print(f"Accessing {name}")
        # MUST use super() to avoid infinite recursion
        return super().__getattribute__(name)

    def method(self):
        return "hello"

t = Traced()
t.method()
# Prints: Accessing method
# Returns: "hello"

Warning: Inside __getattribute__, any access to self.anything triggers another __getattribute__ call. Always delegate to super().__getattribute__() or object.__getattribute__(self, name).

The __getattr__ Pattern for Proxy Objects

class Proxy:
    def __init__(self, target):
        object.__setattr__(self, '_target', target)

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

    def __setattr__(self, name, value):
        if name.startswith('_'):
            object.__setattr__(self, name, value)
        else:
            setattr(self._target, name, value)

Class Attribute Lookup (Metaclass Chain)

When you access an attribute on a class (not an instance), a different chain applies:

# Accessing MyClass.attr
# Uses type.__getattribute__(MyClass, 'attr')

The algorithm is similar but operates on the metaclass hierarchy:

  1. Data descriptors on the metaclass chain.
  2. The class’s own __dict__ and its MRO.
  3. Non-data descriptors on the metaclass chain.
  4. __getattr__ on the metaclass.

This is how classmethod and staticmethod work — they’re descriptors found during class-level lookup.

CPython Optimizations

Attribute Cache

CPython maintains a per-type attribute cache (tp_cache) that maps attribute names to their resolved locations. After the first lookup of obj.attr, subsequent lookups for the same attribute on objects of the same type hit the cache — O(1) instead of scanning the MRO.

The cache is invalidated when:

  • A class’s __dict__ changes.
  • A parent class’s __dict__ changes.
  • The MRO changes (rare, but possible with dynamic class modification).

LOAD_ATTR Specialization (Python 3.11+)

Python 3.11’s specializing interpreter optimizes attribute access:

  • LOAD_ATTR_INSTANCE_VALUE — direct offset access for known instance attributes.
  • LOAD_ATTR_SLOT — optimized slot descriptor access.
  • LOAD_ATTR_MODULE — specialized for module attribute lookups.
  • LOAD_ATTR_WITH_HINT — uses dict key hints for faster instance dict lookups.

These specializations can make attribute access nearly as fast as local variable access in hot loops.

Attribute Setting: The Write Side

obj.attr = value follows a simpler chain:

  1. Check for a data descriptor with __set__ on the class hierarchy.
  2. If found, call descriptor.__set__(obj, value).
  3. Otherwise, store in obj.__dict__[attr] = value.
class Validated:
    class PositiveInt:
        def __set_name__(self, owner, name):
            self.name = name
            self.storage = f"_{name}"

        def __get__(self, obj, objtype=None):
            if obj is None:
                return self
            return getattr(obj, self.storage, 0)

        def __set__(self, obj, value):
            if not isinstance(value, int) or value < 0:
                raise ValueError(f"{self.name} must be a positive integer")
            setattr(obj, self.storage, value)

    count = PositiveInt()
    size = PositiveInt()

Attribute Deletion

del obj.attr follows the same pattern:

  1. Check for a data descriptor with __delete__.
  2. If found, call descriptor.__delete__(obj).
  3. Otherwise, delete from obj.__dict__.

Practical Application: Building an ORM Field

Understanding the lookup chain is essential for building ORM-like systems:

class Field:
    """A data descriptor that stores values in instance.__dict__ with a prefix."""
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = f"_field_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Class-level access returns the descriptor itself
        value = obj.__dict__.get(self.private_name)
        if value is None:
            raise ValueError(f"{self.public_name} has not been set")
        return value

    def __set__(self, obj, value):
        # Validation, type coercion, dirty tracking go here
        obj.__dict__[self.private_name] = value


class Model:
    name = Field()
    email = Field()

The __set_name__ hook (Python 3.6+) tells the descriptor what attribute name it was assigned to, making it self-configuring.

One thing to remember: Python’s attribute lookup is a precisely ordered protocol: data descriptors → instance dict → non-data descriptors → __getattr__. Understanding this chain is what separates developers who use Python from developers who understand Python. Every property, method, slot, and ORM field relies on this mechanism.

pythonadvancedoopinternals

See Also