coding by Ryan Caldwell

Descriptors: Reusable Attribute Control in Python

Learn how to implement reusable attribute control in Python using properties, descriptors, and decorators to manage class attributes with validation, type

Descriptors and Controlled Attributes in Python

Unlike properties, which offer a simple decorator-based approach to attribute access, descriptors provide a lower-level protocol for controlling how attributes behave across multiple classes. When properties fall short for reusable attribute logic, descriptors step in as Python’s mechanism for defining custom attribute behavior.

The Problem It Solves

Python developers often need to enforce validation rules or transform data when accessing object attributes. While properties work well for individual attributes within a single class, they become repetitive when the same logic applies to multiple attributes or across different classes. Descriptors solve this by allowing developers to define attribute behavior once and reuse it wherever needed.

Descriptors control what happens when an attribute is accessed, set, or deleted. They implement a protocol through special methods: __get__, __set__, and __delete__. When an object’s attribute is a descriptor, Python automatically calls these methods instead of performing default attribute access. This enables centralized control over attribute behavior without duplicating code.

The descriptor protocol powers many Python features developers use daily. Properties themselves are implemented using descriptors, as are class methods and static methods. Understanding descriptors reveals how these built-in features work under the hood.

How It Works

A descriptor is any object that defines at least one of three methods: __get__(self, obj, type=None), __set__(self, obj, value), or __delete__(self, obj). When a descriptor is assigned as a class attribute, Python intercepts attribute access on instances of that class.

For example, a descriptor that validates numeric ranges might look like this:

 def __init__(self, min_value, max_value):
 self.min_value = min_value
 self.max_value = max_value
 
 def __set_name__(self, owner, name):
 self.name = name
 
 def __get__(self, obj, type=None):
 if obj is None:
 return self
 return obj.__dict__.get(self.name)
 
 def __set__(self, obj, value):
 if not (self.min_value <= value <= self.max_value):
 raise ValueError(f"{self.name} must be between {self.min_value} and {self.max_value}")
 obj.__dict__[self.name] = value

The __set_name__ method, added in Python 3.6, automatically receives the attribute name when the descriptor is assigned to a class. This eliminates the need to manually specify names.

Descriptors fall into two categories: data descriptors and non-data descriptors. Data descriptors define both __get__ and __set__, while non-data descriptors define only __get__. This distinction matters because data descriptors take precedence over instance dictionary entries during attribute lookup, while non-data descriptors do not.

Setup Guide

To create a reusable descriptor, define a class with the appropriate protocol methods. The descriptor is then assigned as a class attribute:

 price = RangeValidator(0, 10000)
 quantity = RangeValidator(0, 1000)
 
 def __init__(self, price, quantity):
 self.price = price
 self.quantity = quantity

When creating an instance, the descriptor’s __set__ method validates values automatically. Attempting to assign an out-of-range value raises a ValueError.

For descriptors that need to store data per instance, use the instance’s __dict__ rather than storing data on the descriptor itself. Since the descriptor is a class attribute shared across all instances, storing data directly on the descriptor would cause instances to overwrite each other’s values.

Ecosystem

Descriptors integrate with Python’s attribute lookup mechanism, which follows a specific order. When accessing an attribute, Python first checks for data descriptors in the class hierarchy, then the instance dictionary, then non-data descriptors, and finally raises AttributeError if nothing is found.

This protocol enables advanced patterns like lazy evaluation, type checking, and automatic database field mapping in ORMs. Many popular frameworks rely on descriptors internally. Django’s model fields use descriptors to map Python attributes to database columns, while SQLAlchemy employs them for similar purposes.

For simpler cases where attribute logic applies to a single class, properties remain the more readable choice. Descriptors shine when building reusable components or frameworks that need consistent attribute behavior across multiple classes.