Descriptors and the Attribute Access Protocol
Descriptors and the Attribute Access Protocol
After reading this chapter, you will master Python’s descriptor protocol (__get__, __set__, __delete__, and __set_name__), analyze the exact priority order of attribute lookup in CPython, differentiate data descriptors from non-data descriptors, implement reusable type-validation descriptors, and uncover how @property and @classmethod work under the hood.
Mental model
In Python, accessing an attribute (obj.attribute) does not merely read a memory offset. Instead, it triggers an evaluation pipeline. A descriptor is an object that customizes what happens when an attribute is accessed, modified, or deleted on another class:
CPython Attribute Lookup Resolution Pipeline: obj.attribute
1. Look up 'attribute' in obj.__class__.__mro__
│
┌───────────────┴───────────────┐
▼ ▼
Found a Data Descriptor? Not a Data Descriptor
(Defines __set__ or __delete__) │
│ YES ▼
▼ 2. Look up in obj.__dict__
Call descriptor.__get__() │
┌───────────────┴───────────────┐
▼ ▼
Key Found? Key Absent
│ YES │
▼ ▼
Return obj.__dict__['x'] 3. Found Non-Data Descriptor?
(Defines ONLY __get__)
│
┌───────────────┴───────────────┐
▼ ▼
Call __get__() 4. Class __dict__
│
▼
5. Call __getattr__()
The Descriptor Protocol Methods
class Descriptor:
def __set_name__(self, owner: type, name: str) -> None:
"""Called at class creation time; informs descriptor of variable name."""
def __get__(self, instance: object | None, owner: type) -> object:
"""Called when attribute is retrieved (obj.attr)."""
def __set__(self, instance: object, value: object) -> None:
"""Called when attribute is assigned (obj.attr = val). (Makes it a Data Descriptor)."""
def __delete__(self, instance: object) -> None:
"""Called when attribute is deleted (del obj.attr). (Makes it a Data Descriptor)."""Minimal example
Save as descriptor_mechanics.py:
# descriptor_mechanics.py
from typing import Any
class PositiveInteger:
"""Data descriptor that validates assigned integers are strictly positive (> 0)."""
def __set_name__(self, owner: type, name: str) -> None:
# Automatically capture the attribute name (e.g. 'port' or 'workers')
self.private_name = f"_{name}"
def __get__(self, instance: Any, owner: type) -> Any:
if instance is None:
# Accessed on the class itself (e.g. ServerConfig.port)
return self
return getattr(instance, self.private_name, None)
def __set__(self, instance: Any, value: Any) -> None:
if not isinstance(value, int) or value <= 0:
raise ValueError(f"Attribute '{self.private_name[1:]}' must be a positive integer, got: {value!r}")
setattr(instance, self.private_name, value)
class ServerConfig:
port = PositiveInteger()
workers = PositiveInteger()
def __init__(self, port: int, workers: int) -> None:
self.port = port # Triggers PositiveInteger.__set__
self.workers = workers # Triggers PositiveInteger.__set__
def main() -> None:
# 1. Valid assignment
cfg = ServerConfig(port=8080, workers=4)
print(f"Valid ServerConfig: port={cfg.port}, workers={cfg.workers}")
# 2. Validation rejection
try:
cfg.port = -1
except ValueError as err:
print(f"Caught expected validation failure: {err}")
if __name__ == "__main__":
main()Run via uv run python descriptor_mechanics.py:
Valid ServerConfig: port=8080, workers=4
Caught expected validation failure: Attribute 'port' must be a positive integer, got: -1
Worked examples
Case 1: How @property Works Under the Hood
The built-in @property decorator is actually a standard data descriptor implemented in C. Writing a pure-Python property replica reveals how getter, setter, and deleter methods bind together:
# property_replica.py
from collections.abc import Callable
from typing import Any
class CustomProperty:
"""Pure-Python implementation of the built-in property descriptor."""
def __init__(
self,
fget: Callable[[Any], Any] | None = None,
fset: Callable[[Any, Any], None] | None = None,
) -> None:
self.fget = fget
self.fset = fset
def __get__(self, instance: Any, owner: type) -> Any:
if instance is None:
return self
if self.fget is None:
raise AttributeError("Unreadable attribute")
return self.fget(instance)
def __set__(self, instance: Any, value: Any) -> None:
if self.fset is None:
raise AttributeError("Can't set attribute (read-only)")
self.fset(instance, value)
def setter(self, fset: Callable[[Any, Any], None]) -> "CustomProperty":
"""Return a new descriptor instance with the setter attached."""
return CustomProperty(fget=self.fget, fset=fset)
class TemperatureSensor:
def __init__(self, celsius: float) -> None:
self._celsius = celsius
@CustomProperty
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Temperature below absolute zero is physically impossible!")
self._celsius = value
def main() -> None:
sensor = TemperatureSensor(25.0)
print(f"Initial temperature: {sensor.celsius} C")
sensor.celsius = 32.5
print(f"Updated temperature: {sensor.celsius} C")
try:
sensor.celsius = -300.0
except ValueError as err:
print(f"Caught absolute zero error: {err}")
if __name__ == "__main__":
main()Run:
uv run python property_replica.pyOutput:
Initial temperature: 25.0 C
Updated temperature: 32.5 C
Caught absolute zero error: Temperature below absolute zero is physically impossible!
Case 2: Lazy Cached Properties with Non-Data Descriptors
A non-data descriptor defines only __get__. Because it lacks __set__, Python’s lookup rule dictates that entries in instance.__dict__ take precedence over it! This mechanism enables efficient lazy-evaluation caches:
# lazy_cached_property.py
import time
from collections.abc import Callable
from typing import Any
class LazyCachedProperty:
"""Non-data descriptor: computes once, stores directly in instance.__dict__."""
def __init__(self, func: Callable[[Any], Any]) -> None:
self.func = func
self.attr_name = func.__name__
def __get__(self, instance: Any, owner: type) -> Any:
if instance is None:
return self
print(f" [Computing] Running expensive calculation for '{self.attr_name}'...")
val = self.func(instance)
# Store directly in the instance dict!
# Subsequent lookups will find it in instance.__dict__ and bypass this descriptor entirely!
instance.__dict__[self.attr_name] = val
return val
class CloudNodeMetrics:
def __init__(self, node_id: str) -> None:
self.node_id = node_id
@LazyCachedProperty
def historical_aggregate(self) -> dict[str, float]:
time.sleep(0.01) # Simulate expensive database aggregation
return {"avg_cpu": 72.4, "p99_latency_ms": 142.1}
def main() -> None:
node = CloudNodeMetrics("srv-us-east-42")
print("First access (triggers computation):")
print(f"Result: {node.historical_aggregate}")
print("\nSecond access (retrieved instantly from instance.__dict__):")
print(f"Result: {node.historical_aggregate}")
print(f"\nVerifying stored attributes in node.__dict__:")
print(f"Keys: {list(node.__dict__.keys())}")
if __name__ == "__main__":
main()Run:
uv run python lazy_cached_property.pyOutput:
First access (triggers computation):
[Computing] Running expensive calculation for 'historical_aggregate'...
Result: {'avg_cpu': 72.4, 'p99_latency_ms': 142.1}
Second access (retrieved instantly from instance.__dict__):
Result: {'avg_cpu': 72.4, 'p99_latency_ms': 142.1}
Verifying stored attributes in node.__dict__:
Keys: ['node_id', 'historical_aggregate']
Pitfalls
Pitfall 1: Storing Value on the Descriptor Instance
Descriptors are instantiated once at the class level, shared across all instances of that class. Storing user state on self.value causes every class instance to overwrite the exact same variable:
# THE TRAP: Shared state across all class instances!
class BrokenDescriptor:
def __get__(self, instance, owner):
return self.val
def __set__(self, instance, val):
self.val = val # BUG: Stored on the descriptor, shared across all objects!
class Worker:
worker_id = BrokenDescriptor()
w1 = Worker()
w2 = Worker()
w1.worker_id = "worker-01"
w2.worker_id = "worker-02"
print(f"w1 ID: {w1.worker_id}") # Prints 'worker-02'! w1 was overwritten!# THE FIX: Store the state inside the instance, keyed by private_name
class SafeDescriptor:
def __set_name__(self, owner, name):
self.storage_key = f"_{name}"
def __get__(self, instance, owner):
if instance is None: return self
return getattr(instance, self.storage_key, None)
def __set__(self, instance, val):
setattr(instance, self.storage_key, val)Pitfall 2: Forgetting if instance is None: in __get__
When an attribute is accessed via the class itself (MyClass.attr), the instance argument passed to __get__ is None:
class MyDescriptor:
def __get__(self, instance, owner):
# BUG: if instance is None, instance.data raises AttributeError!
# if instance is None: return self
return instance.data
# THE FIX:
class RobustDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self # Return descriptor object when inspected via class
return getattr(instance, "_data", None)Exercises
- Implement a
TypedStringdescriptor that validates that assigned values are instances ofstrand have a length betweenmin_lenandmax_len. - Replicate
functools.cached_propertyusing a non-data descriptor that caches expensive method results directly ontoinstance.__dict__. - Explain why defining
__set__(even as a no-op that raisesAttributeError) prevents assignments toinstance.__dict__from shadowing the descriptor. - Implement a
ReadOnlydescriptor that allows an attribute to be assigned exactly once (during__init__), but raisesRuntimeErroron subsequent modification attempts. - Create a descriptor
BoundedFloat(min_val, max_val)and use it to validate percentage metrics on aSystemMonitorclass.
Further reading
- Raymond Hettinger: Descriptor HowTo Guide (Official Python Documentation).
- Python Language Reference: Implementing Descriptors.
- PEP 487: Simpler customisation of class creation (
__set_name__protocol).