Descriptors and the Attribute Access Protocol

Updated

September 7, 2026

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.py

Output:

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.py

Output:

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

  1. Implement a TypedString descriptor that validates that assigned values are instances of str and have a length between min_len and max_len.
  2. Replicate functools.cached_property using a non-data descriptor that caches expensive method results directly onto instance.__dict__.
  3. Explain why defining __set__ (even as a no-op that raises AttributeError) prevents assignments to instance.__dict__ from shadowing the descriptor.
  4. Implement a ReadOnly descriptor that allows an attribute to be assigned exactly once (during __init__), but raises RuntimeError on subsequent modification attempts.
  5. Create a descriptor BoundedFloat(min_val, max_val) and use it to validate percentage metrics on a SystemMonitor class.

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).