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:
| Hook | When It Runs | Risk Level |
|---|---|---|
__getattribute__ | On every attribute access | High — easy to cause infinite recursion |
__getattr__ | Only when normal lookup fails | Low — 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:
- Data descriptors on the metaclass chain.
- The class’s own
__dict__and its MRO. - Non-data descriptors on the metaclass chain.
__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:
- Check for a data descriptor with
__set__on the class hierarchy. - If found, call
descriptor.__set__(obj, value). - 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:
- Check for a data descriptor with
__delete__. - If found, call
descriptor.__delete__(obj). - 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.
See Also
- Python Bytecode And Interpreter How your .py file turns into tiny instructions the Python interpreter can execute step by step.
- Python Class Body Execution Python runs the code inside your class definition immediately — like reading a recipe out loud before anyone starts cooking.
- Python Data Model Customization How Python lets your objects behave like built-in types — adding, comparing, looping, and printing, all with special methods.
- Python Garbage Collection See how Python cleans up unreachable objects, especially the tricky ones that point at each other.
- Python Gil Why Python threads can feel stuck in traffic, and how the GIL explains the behavior.