Python Descriptors: Using get, set, and delete

The Python descriptor protocol provides a standardized way to customize what happens when an object's attribute is accessed, modified, or deleted. By implementing any combination of the __get__(), __set__(), and __delete__() special methods, a class can become a descriptor and take control over attribute operations on another class. This protocol serves as the foundational mechanism behind core Python features such as @property, methods, classmethod, and staticmethod, as well as Object-Relational Mapping (ORM) field definitions.

What is a Descriptor?

A descriptor is an instance of a class that implements at least one method of the descriptor protocol:

To function, a descriptor must always be assigned as a class attribute on another class, never directly to an instance.


The Descriptor Methods Explained

1. __get__(self, obj, objtype=None)

Invoked when an attribute is read.

class VerboseGet:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Accessed from the class level
        return f"Accessed from instance: {obj}"

class Example:
    attribute = VerboseGet()

inst = Example()
print(inst.attribute)       # Accessed from instance: <Example object at ...>
print(Example.attribute)    # <VerboseGet object at ...>

2. __set__(self, obj, value)

Invoked when a value is assigned to the attribute (e.g., obj.attr = value).

Defining __set__ allows data validation, transformation, or restricted access before saving the value into the instance's internal __dict__.

3. __delete__(self, obj)

Invoked when an attribute is deleted using the del statement (e.g., del obj.attr).


Data vs. Non-Data Descriptors

Python handles attribute resolution based on whether a descriptor is classified as a "data" or "non-data" descriptor:


Practical Implementation: A Validated Field

Here is an example demonstrating all three methods by creating a descriptor that enforces non-negative integer values.

class NonNegativeInteger:
    def __set_name__(self, owner, name):
        # Automatically stores the attribute name (e.g., '_price')
        self.storage_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, 0)

    def __set__(self, obj, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{self.storage_name[1:]} must be a non-negative integer.")
        setattr(obj, self.storage_name, value)

    def __delete__(self, obj):
        if hasattr(obj, self.storage_name):
            delattr(obj, self.storage_name)
        else:
            raise AttributeError(f"{self.storage_name[1:]} not found.")

class Product:
    price = NonNegativeInteger()
    quantity = NonNegativeInteger()

    def __init__(self, price, quantity):
        self.price = price
        self.quantity = quantity

Usage:

item = Product(price=50, quantity=10)
print(item.price)     # Triggers __get__, prints: 50

item.price = 100      # Triggers __set__
# item.price = -5     # Triggers __set__, raises ValueError

del item.price        # Triggers __delete__

Attribute Lookup Order

When an attribute is accessed via obj.attribute, Python’s object.__getattribute__() executes the following sequence:

  1. Checks if the attribute is a data descriptor on the class.
  2. If not, checks the instance __dict__.
  3. If not in the instance __dict__, checks if it is a non-data descriptor on the class.
  4. If not a non-data descriptor, retrieves standard class attributes.
  5. If the attribute is nowhere to be found, calls __getattr__() if implemented; otherwise, raises AttributeError.