Attribute Access and Lookup¶
Understanding Python's attribute access system—including the lookup hierarchy, descriptor protocol, and access hooks—is essential for advanced OOP.
Attribute Resolution Order¶
When you access obj.attr, Python searches in this order:
- Data descriptors from
type(obj)and its bases - Instance attributes from
obj.__dict__ - Non-data descriptors from
type(obj)and its bases - Class attributes from
type(obj)and its bases __getattr__if defined and attribute not found
Visual Flow¶
obj.attr
↓
__getattribute__ called
↓
Check data descriptors in class
↓
Check instance __dict__
↓
Check non-data descriptors in class
↓
Check class attributes
↓
__getattr__ (if defined)
↓
AttributeError
Key Principle¶
Data descriptors override instance attributes, while non-data descriptors defer to instance attributes.
Data vs Non-Data Descriptors¶
The key distinction affecting lookup priority:
| Type | Methods | Priority |
|---|---|---|
| Data descriptor | __get__ + __set__ or __delete__ |
Before instance __dict__ |
| Non-data descriptor | Only __get__ |
After instance __dict__ |
Key principle: Data descriptors override instance attributes; non-data descriptors defer to them.
See Data vs Non-Data Descriptors for detailed examples, use cases, and patterns.
The Four Access Hooks¶
Python provides four attribute access hooks:
| Method | Called When | Fallback |
|---|---|---|
__getattribute__ |
Every attribute read | None |
__getattr__ |
Missing attribute only | None |
__setattr__ |
Every attribute write | None |
__delattr__ |
Every attribute deletion | None |
Relationship Diagram¶
Attribute Read: obj.x
↓
__getattribute__('x')
↓
Found? → Return value
↓
Not found? → AttributeError
↓
__getattr__('x') if defined
↓
Return value or raise AttributeError
Attribute Write: obj.x = value
↓
__setattr__('x', value)
↓
Store in __dict__ or descriptor
Attribute Delete: del obj.x
↓
__delattr__('x')
↓
Remove from __dict__ or descriptor
Combined Implementation¶
All Methods Together¶
class FullControl:
def __init__(self, value):
# Careful: __setattr__ is active here!
super().__setattr__('_data', {})
self.value = value # Uses __setattr__
def __getattribute__(self, name):
print(f"[GET] {name}")
return super().__getattribute__(name)
def __getattr__(self, name):
print(f"[MISSING] {name}")
return f"Default for {name}"
def __setattr__(self, name, value):
print(f"[SET] {name} = {value}")
super().__setattr__(name, value)
def __delattr__(self, name):
print(f"[DEL] {name}")
super().__delattr__(name)
obj = FullControl(42)
# [SET] value = 42
print(obj.value)
# [GET] value
# 42
print(obj.missing)
# [GET] missing
# [MISSING] missing
# Default for missing
With Properties¶
class WithProperties:
def __init__(self):
self._value = 0
@property
def value(self):
print("[PROPERTY GET]")
return self._value
@value.setter
def value(self, val):
print("[PROPERTY SET]")
self._value = val
def __getattribute__(self, name):
print(f"[__getattribute__] {name}")
return super().__getattribute__(name)
def __setattr__(self, name, value):
print(f"[__setattr__] {name}")
super().__setattr__(name, value)
obj = WithProperties()
# [__setattr__] _value
obj.value = 42
# [__setattr__] value
# [PROPERTY SET]
# [__setattr__] _value
print(obj.value)
# [__getattribute__] value
# [PROPERTY GET]
# [__getattribute__] _value
# 42
MRO and Inheritance¶
Method Resolution Order¶
For inherited classes, Python follows the MRO:
class A:
x = "A"
class B(A):
pass
class C(A):
x = "C"
class D(B, C):
pass
print(D.mro())
# [D, B, C, A, object]
print(D.x) # "C" (found in C before A)
Searching Through MRO¶
class Base:
def method(self):
return "Base"
class Child(Base):
pass
obj = Child()
# Searches: Child → Base → object
print(obj.method()) # "Base"
Descriptor Protocol¶
How Descriptors Work¶
When accessing obj.attr:
# Python internally does:
type(obj).__dict__['attr'].__get__(obj, type(obj))
Methods as Descriptors¶
Functions are non-data descriptors:
class MyClass:
def method(self):
return "method called"
obj = MyClass()
# Function in class dict
print(type(MyClass.__dict__['method'])) # <class 'function'>
# Descriptor protocol creates bound method
print(type(obj.method)) # <class 'method'>
Descriptor Access Levels¶
class MyDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self # Accessed from class
return "value" # Accessed from instance
class MyClass:
attr = MyDescriptor()
print(MyClass.attr) # <MyDescriptor object>
print(MyClass().attr) # "value"
Practical Patterns¶
Read-Only After Init¶
class ReadOnlyAfterInit:
def __init__(self):
super().__setattr__('_locked', False)
self.value = 42
super().__setattr__('_locked', True)
def __setattr__(self, name, value):
if super().__getattribute__('_locked'):
raise AttributeError("Object is read-only")
super().__setattr__(name, value)
def __delattr__(self, name):
if super().__getattribute__('_locked'):
raise AttributeError("Object is read-only")
super().__delattr__(name)
Lazy Loading with Caching¶
class LazyCache:
def __init__(self):
super().__setattr__('_cache', {})
super().__setattr__('_loaders', {})
def register_loader(self, attr, loader_func):
self._loaders[attr] = loader_func
def __getattr__(self, name):
# Called only for missing attributes
loaders = super().__getattribute__('_loaders')
cache = super().__getattribute__('_cache')
if name in loaders:
print(f"[LOADING] {name}")
value = loaders[name]()
cache[name] = value
setattr(self, name, value) # Cache in __dict__
return value
raise AttributeError(f"No attribute: {name}")
obj = LazyCache()
obj.register_loader('data', lambda: [1, 2, 3, 4, 5])
print(obj.data) # [LOADING] data → [1, 2, 3, 4, 5]
print(obj.data) # [1, 2, 3, 4, 5] (from __dict__, no loading)
Validation System¶
class ValidatedObject:
_validators = {}
def __setattr__(self, name, value):
if name in self._validators:
if not self._validators[name](value):
raise ValueError(f"Validation failed for {name}")
super().__setattr__(name, value)
@classmethod
def add_validator(cls, attr, validator):
cls._validators[attr] = validator
ValidatedObject.add_validator('age', lambda x: 0 <= x <= 150)
obj = ValidatedObject()
obj.age = 30 # ✓ OK
# obj.age = 200 # ✗ ValueError
Common Pitfalls¶
Forgetting super()¶
# ✗ BAD - doesn't actually store value
def __setattr__(self, name, value):
print(f"Setting {name}")
# Forgot super().__setattr__!
# ✓ GOOD
def __setattr__(self, name, value):
print(f"Setting {name}")
super().__setattr__(name, value)
Infinite Recursion¶
# ✗ BAD - infinite recursion
def __getattribute__(self, name):
if self.ready: # Calls __getattribute__ again!
return self.value
# ✓ GOOD
def __getattribute__(self, name):
ready = super().__getattribute__('ready')
if ready:
return super().__getattribute__('value')
return super().__getattribute__(name)
Shadowing Class Attributes¶
class Counter:
count = 0
def increment(self):
self.count += 1 # ✗ Creates instance attribute!
c1 = Counter()
c1.increment()
print(c1.count) # 1 (instance)
print(Counter.count) # 0 (class unchanged)
# ✓ Fix: modify class attribute directly
def increment(self):
Counter.count += 1
Understanding __dict__¶
class Example:
class_var = "class"
obj = Example()
obj.instance_var = "instance"
print(obj.__dict__) # {'instance_var': 'instance'}
print(Example.__dict__) # {..., 'class_var': 'class', ...}
When to Use Each Method¶
| Method | Use When |
|---|---|
__getattribute__ |
Need to intercept all reads (logging, proxies) |
__getattr__ |
Need default values for missing attributes |
__setattr__ |
Need to validate or transform all writes |
__delattr__ |
Need to protect or cleanup on deletion |
Best Practices¶
- Use the minimum necessary — Don't override all four if you don't need to
- Call super() — Always delegate to parent implementation
- Avoid recursion — Never access
self.xinside__getattribute__ - Consider properties first — They're simpler for specific attributes
- Document behavior — Make it clear which attributes are special
Summary¶
| Concept | Key Point |
|---|---|
| Resolution order | Data descriptors → instance → non-data → class → __getattr__ |
| Data descriptor | Has __set__ or __delete__, overrides instance |
| Non-data descriptor | Only __get__, defers to instance |
__getattribute__ |
Called for every attribute access |
__getattr__ |
Fallback for missing attributes only |
| MRO | C3 linearization determines search order |
Key Takeaways:
- Attribute lookup follows a strict hierarchy
- Data descriptors have highest priority (e.g., properties with setters)
- Always use
super().__getattribute__()inside__getattribute__to avoid recursion __getattr__is only called when attribute is not found normally- Properties are simpler than
__getattribute__for specific attributes