Attribute Access and Lookup¶
Understanding Python's attribute access system—including the lookup hierarchy, descriptor protocol, and access hooks—is essential for advanced OOP.
Mental Model
Think of attribute access as a pipeline: every time you write obj.attr, Python walks a chain of checks—data descriptors, instance dictionaries, non-data descriptors, class hierarchies, and fallback hooks—before returning a value. Without a descriptor, Python returns the stored value directly. With a descriptor, Python calls its __get__ — the descriptor decides what to return.
Attribute Resolution Order¶
When you access obj.attr, Python searches in this order:
- Data descriptors — search each class's
__dict__in MRO order for a data descriptor - Instance attributes — check
obj.__dict__ - Non-data descriptors and class attributes — search MRO again for non-data descriptors or plain attributes
__getattr__— called only if nothing was found above
Phases, Not Per-Class Loops
A common misconception is that Python runs steps 1–3 per class before moving to the next class in MRO. It does not. Each step is a phase across the entire MRO:
- Phase 1 scans all classes in MRO for data descriptors
- Phase 2 checks the instance
__dict__ - Phase 3 scans all classes in MRO again for non-data descriptors / plain attributes
This is why a data descriptor in a parent class overrides an instance attribute — Python finds it in phase 1, before ever reaching phase 2.
Performance: this sounds expensive, but CPython optimizes heavily — cached attribute lookups, type version tags, fast paths in LOAD_ATTR bytecode. Most MROs are short (2–3 classes), so even the logical search is small. The lookup rules are designed for correctness first; CPython then optimizes the common cases.
Visual Flow¶
obj.attr
↓
__getattribute__ called
↓
[1] Search MRO for data descriptor → call __get__
↓
[2] Check instance __dict__
↓
[3] Search MRO for non-data descriptor / plain attribute
↓
[4] __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__ |
See Data vs Non-Data Descriptors for detailed examples, priority demonstrations, 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¶
```python 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¶
```python 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:
```python 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¶
```python class Base: def method(self): return "Base"
class Child(Base): pass
obj = Child()
Searches: Child → Base → object¶
print(obj.method()) # "Base" ```
Descriptor Protocol¶
Descriptors integrate directly into this lookup pipeline. Without a descriptor, Python returns the stored value directly. With a descriptor, Python calls its __get__ method — the descriptor decides what to return:
```python
Python internally does:¶
type(obj).dict['attr'].get(obj, type(obj)) ```
See Descriptor Introduction for what descriptors are and when to use them, and Descriptor Methods for the full __get__, __set__, __delete__ protocol.
Practical Patterns¶
Read-Only After Init¶
```python 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¶
```python 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¶
```python 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¶
```
Debugging Attribute Access¶
Debugging Rule
If obj.attr behaves unexpectedly, check in this order:
- Is it a property or descriptor? → Check
type(type(obj).__dict__.get('attr')) - Is it in
obj.__dict__? → Checkobj.__dict__ - Is it in the class? → Check
type(obj).__dict__ - Is
__getattr__involved? → Check if the class defines__getattr__
Common Pitfalls¶
Forgetting super()¶
```python
✗ 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¶
```python
✗ 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¶
```python 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__¶
```python 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 (MRO) → instance __dict__ → non-data / class attrs (MRO) → __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
Exercises¶
Exercise 1.
Create a class LoggedAccess that implements __getattribute__ to print a message every time any attribute is accessed. Use super().__getattribute__() to avoid infinite recursion. Demonstrate it with a simple class that has name and value attributes.
Solution to Exercise 1
class LoggedAccess:
def __getattribute__(self, name):
print(f"Accessing attribute: {name}")
return super().__getattribute__(name)
class Item(LoggedAccess):
def __init__(self, name, value):
self.name = name
self.value = value
item = Item("widget", 42)
print(item.name) # prints "Accessing attribute: name" then "widget"
print(item.value) # prints "Accessing attribute: value" then "42"
Exercise 2.
Write a class DefaultDict that uses __getattr__ to return a default value ("N/A") for any attribute that does not exist, instead of raising AttributeError. Set a few attributes in __init__ and show that existing attributes return their values while missing attributes return the default.
Solution to Exercise 2
class DefaultDict:
def __init__(self, **kwargs):
for k, v in kwargs.items():
self.__dict__[k] = v
def __getattr__(self, name):
return "N/A" # Default for missing attributes
d = DefaultDict(name="Alice", age=30)
print(d.name) # Alice
print(d.age) # 30
print(d.email) # N/A — does not exist
print(d.phone) # N/A — does not exist
Exercise 3.
Build a class Frozen that allows attributes to be set in __init__ but prevents any attribute modification after initialization. Use a flag _initialized and override __setattr__ to raise AttributeError if _initialized is True. Demonstrate that attributes can be set during construction but not after.
Solution to Exercise 3
class Frozen:
def __init__(self, **kwargs):
for k, v in kwargs.items():
object.__setattr__(self, k, v)
object.__setattr__(self, '_initialized', True)
def __setattr__(self, name, value):
if getattr(self, '_initialized', False):
raise AttributeError(f"Cannot modify attribute '{name}' on frozen object")
super().__setattr__(name, value)
f = Frozen(x=10, y=20)
print(f.x) # 10
print(f.y) # 20
try:
f.x = 99
except AttributeError as e:
print(f"Error: {e}")
# Error: Cannot modify attribute 'x' on frozen object