Programming · free software · the open webSubscribe via RSS

Descriptors turn attribute access into an interface policy

A descriptor lets a class attribute mediate reads and writes. Understanding its precedence, owner binding and storage name helps decide when the protocol is clearer than a property or ordinary method.

A class attribute can participate in a lookup

Python’s dot syntax looks simple because much of its behavior is centralized in the data model. When an attribute lookup finds an object in a class dictionary, that object may control what the lookup returns. An object implementing __get__, __set__, or __delete__ is a descriptor; the interpreter invokes its protocol when it is found as a class attribute. [1] [2]

That makes the descriptor instance a reusable policy object, while the accessed instance supplies the per-object context. A field declaration such as price = PositiveNumber() can therefore say that every price read and write follows the same rule, without writing a property pair in every model class. The key architectural move is to keep policy on the class and value on the instance.

Precedence explains surprising overrides

For an instance lookup, a data descriptor takes precedence over a same-named value in the instance dictionary. A descriptor that defines __set__ or __delete__ is a data descriptor; adding even a __set__ method that raises AttributeError makes it data-like for precedence purposes. A non-data descriptor defines __get__ but neither setter nor deleter, so an instance dictionary value can shadow it. [1]

This distinction explains why methods can be replaced on one instance while a property generally cannot be bypassed that way. Functions stored in a class are non-data descriptors: binding a method supplies the instance as its first argument. property is a data descriptor. The difference is observable design behavior, not a special category of syntax. When debugging a mysterious attribute, inspect both vars(obj) and vars(type(obj)) before assuming the value is stored where the dot makes it appear.

class PositiveInteger:
    def __set_name__(self, owner, name):
        self.storage_name = "_" + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
            raise ValueError("quantity must be a positive integer")
        setattr(instance, self.storage_name, value)

class Item:
    quantity = PositiveInteger()

item = Item()
item.quantity = 4
print(item.quantity)
try:
    item.quantity = 0
except ValueError as error:
    print(type(error).__name__)
Python 3 · standard language features · validation example

Use the owner and instance deliberately

On obj.field, Python calls the descriptor’s __get__ with the descriptor object as self, the target object as instance, and its class as owner. On Model.field, the instance argument is None; that gives a descriptor a useful class-level inspection path. A descriptor that needs no class access can return itself in that case, while one can expose metadata or a default instead. [1] [2]

A descriptor assigned to a class is usually shared by all instances of that class. Storing the actual field value directly on the descriptor would make one object’s value visible to every instance. Instead, __set_name__ can capture the declared attribute name when the owner class is created, and the descriptor can store each value under a private key in the individual instance. The storage key must differ from the public name: writing setattr(instance, name, value) from __set__ would invoke the descriptor again and recurse.

A small validator, with its boundaries

The example accepts only positive integers and stores them as _quantity. The descriptor is a data descriptor because it implements both __get__ and __set__. __set_name__ runs during class creation and gives the descriptor a stable private storage key. The assignment item.quantity = 4 calls its setter; reading item.quantity calls its getter. The short program prints 4, then raises ValueError for zero. This is an executable illustration, not a complete validation framework: bool is a subclass of int, so the example explicitly excludes it, and production code may need serialization, inheritance, deletion, or error-message conventions. [2]

Descriptors are a good fit when one access rule is reused across several classes or fields, especially when validation or computed access is itself part of the model. A property is usually easier for one class with one special field; a plain method is more explicit when reading performs a costly operation or can fail. The protocol has real lookup consequences, so it should earn its abstraction. Review the desired override behavior, where data lives, and how class access behaves before introducing one.

References

  1. Python data model
  2. Python Descriptor Guide

Suggest a correction · All posts