arthrop0d

Brief articles on varied topics.

Python Descriptors and Attribute Access

pythoncomputer science

Python’s descriptor protocol is a set of methods that lets an object control what happens when another object’s attribute is read, assigned, or deleted. The methods __get__, __set__, and __delete__ turn an ordinary class attribute into a hook for custom behavior.

You have already used descriptors if you have used a property, called an instance method, or declared a field in an ORM model. The mechanism is usually invisible, but it explains several familiar behaviors: computed attributes, validation during assignment, methods receiving self automatically, and database fields that do more than store a value.

The problem descriptors solve

Without descriptors, obj.name would effectively mean “look for name in the object’s stored attributes.” Reading would retrieve a value, assignment would replace a value, and deletion would remove one. That is sufficient for plain data, but not for behavior such as:

Python could have added separate language syntax for every one of these features. Instead, it provides one general protocol: an object placed on a class can intercept attribute operations.

A descriptor is an object on a class

A descriptor is an object whose class defines one or more of these methods:

Here is a small descriptor that validates and stores a positive integer:

class PositiveInteger:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.__dict__.get(self.name)

    def __set__(self, instance, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError("value must be a positive integer")
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        instance.__dict__.pop(self.name, None)


class Product:
    stock = PositiveInteger()

stock is a descriptor stored on Product, not on each Product instance. Nevertheless, these operations invoke it:

product = Product()
product.stock = 10       # PositiveInteger.__set__
print(product.stock)      # PositiveInteger.__get__
del product.stock        # PositiveInteger.__delete__

__set_name__ is a separate convenience hook that Python calls when the descriptor is assigned to a class. It lets the descriptor learn that its attribute name is stock; the core interception behavior comes from the three methods above.

The instance argument is the object being accessed, and owner is its class. When the descriptor is accessed through the class itself, as in Product.stock, Python passes instance=None. Descriptors commonly return themselves in that case, which lets framework code inspect or configure the descriptor.

How Python chooses what to call

For an expression such as product.stock, Python’s attribute lookup follows an ordering that matters when both the class and the instance have an attribute with the same name:

  1. A data descriptor on the class is checked first. A data descriptor defines __set__ or __delete__ as well as __get__.
  2. If there is no data descriptor, Python checks the instance’s __dict__, the normal storage for per-object attributes.
  3. If the instance has no value, Python checks the class and its base classes. A descriptor found there can handle the read.
  4. Otherwise, Python returns the class attribute directly or raises AttributeError.

This ordering allows a data descriptor to enforce rules even if code tries to put a same-named value in the instance dictionary. A descriptor with only __get__ is a non-data descriptor, so an instance attribute can override it.

Assignment follows a related rule. If the class provides __set__, Python calls it instead of simply writing to instance.__dict__. If it does not, assignment normally creates or replaces an instance attribute. del product.stock calls __delete__ when available; otherwise ordinary deletion applies or Python raises AttributeError.

Properties use the protocol

A property is a built-in descriptor. It packages functions for reading, writing, and optionally deleting an attribute:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

temperature.celsius calls the property’s read function. Assignment calls its setter, and del temperature.celsius would call a configured deleter. The leading underscore in _celsius is important: it is the separate storage attribute, preventing the property from recursively reading itself.

Bound methods are descriptors too

Functions defined in a class implement __get__. When you write:

class Worker:
    def run(self):
        return "running"

worker = Worker()
action = worker.run

run is stored once on Worker. Accessing it through worker invokes the function’s descriptor behavior, producing a bound method: a callable that remembers worker as its first argument. Calling action() therefore behaves like Worker.run(worker).

Accessing Worker.run instead passes instance=None, so you get the underlying function without an instance automatically supplied. This is why methods can be called through an object while still being ordinary function objects stored on the class.

ORM fields extend the same idea

ORMs, or object-relational mappers, represent database rows as Python objects. A model declaration might look like this:

class User(Model):
    email = StringField(max_length=255, unique=True)

StringField can be a descriptor. On an individual User, user.email may load a value from an internal row dictionary, while user.email = value can validate and mark the field as changed. On the class, User.email can return a field expression used to construct a query rather than a user’s actual email value.

The descriptor protocol does not itself know anything about SQL. The ORM supplies that policy, storage, and query machinery. Descriptors provide the crucial interception point that makes one class declaration work differently when accessed through an instance and through the class.

When a stack trace mentions a property setter, an unexpected AttributeError during assignment, or a model field behaving unlike a plain variable, descriptor lookup is often the mechanism underneath. The key idea is simple: a class attribute can be an active object, not just stored data, and Python consults that object during attribute access.

← All articles