Python Data Descriptors: How They Work and When to Use
python data descriptor: Understand Python data descriptors, the descriptor protocol, and how to implement them for controlled attribute access in your classes.
A Python data descriptor is an object that defines both __get__ and __set__ methods, giving it full control over how an attribute is read and written on a class. This is the mechanism behind property, classmethod, and staticmethod, but you can also build custom descriptors to enforce validation, cache computed values, or manage attribute lifecycle. Understanding the descriptor protocol is essential for writing clean, reusable attribute logic in Python.
The Descriptor Protocol
The descriptor protocol consists of three methods: __get__, __set__, and __delete__. An object that implements __get__ and __set__ is a data descriptor; one that implements only __get__ is a non-data descriptor. The __get__ method is called when the attribute is accessed, __set__ when it is assigned, and __delete__ when it is deleted.
class Descriptor: def __get__(self, instance, owner): return ... def __set__(self, instance, value): ... def __delete__(self, instance): ...
The instance argument is the object whose attribute is being accessed. The owner argument is the class where the descriptor is defined. When accessed on the class itself, instance is None. This distinction matters for implementing behavior that differs between instance and class access.
Data vs Non-Data Descriptors
Python's attribute lookup precedence determines whether a descriptor or an instance dictionary entry wins. For a data descriptor, __get__ takes precedence over the instance dictionary. For a non-data descriptor, the instance dictionary is checked first. This difference is why methods (non-data descriptors) can be overridden by assigning an instance attribute, while property (a data descriptor) cannot.
class NonData: def __get__(self, instance, owner): return "non-data" class Data: def __get__(self, instance, owner): return "data" def __set__(self, instance, value): pass class Example: nd = NonData() d = Data() ex = Example() ex.nd = "instance value" ex.d = "instance value" print(ex.nd) # "instance value" print(ex.d) # "data"
In this example, assigning to ex.nd shadows the non-data descriptor because the instance dictionary takes precedence. Assigning to ex.d still triggers the data descriptor's __set__, and reading it returns the descriptor's __get__ result.
Implementing a Simple Data Descriptor
A common use is enforcing a type or range on an attribute. Here is a descriptor that validates an integer is within a given range:
class IntegerRange: 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, instance, owner): if instance is None: return self return instance.__dict__.get(self.name) def __set__(self, instance, value): if not isinstance(value, int): raise TypeError(f"{self.name} must be an integer") if not self.min_value <= value <= self.max_value: raise ValueError(f"{self.name} must be between {self.min_value} and {self.max_value}") instance.__dict__[self.name] = value class Temperature: celsius = IntegerRange(-273, 1000) t = Temperature() t.celsius = 25 print(t.celsius) # 25 t.celsius = -300 # raises ValueError
The __set_name__ method is called when the descriptor is assigned to a class attribute, giving the descriptor its attribute name. Storing the value in instance.__dict__ avoids recursion because __set__ is called on every assignment; writing to instance.__dict__ directly bypasses the descriptor.
Common Use Cases for Data Descriptors
Data descriptors are useful when you need to centralize behavior that would otherwise be duplicated across properties. Common scenarios include:
- Validation: Enforce types, ranges, or formats before assignment.
- Caching: Compute a value once and store it, invalidating when dependencies change.
- Logging or auditing: Track every read or write to an attribute.
- Lazy loading: Defer expensive computation until the attribute is accessed.
For example, a descriptor can cache a database lookup:
class CachedProperty: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, instance, owner): if instance is None: return self if self.name not in instance.__dict__: instance.__dict__[self.name] = self.func(instance) return instance.__dict__[self.name] class Report: def __init__(self, data): self.data = data @CachedProperty def total(self): return sum(self.data)
This descriptor computes the total once and stores it in the instance dictionary. Subsequent accesses return the cached value without recomputation.
How Descriptors Interact with Class and Instance Attributes
When a descriptor is accessed on the class rather than an instance, __get__ receives instance=None. This is how property returns itself when accessed on the class. A well-behaved descriptor should handle this case explicitly, often by returning self or raising a descriptive error.
Also note that descriptors are stored as class attributes. If you assign a descriptor to an instance, it becomes a plain attribute and loses its descriptor behavior. The descriptor protocol only applies when the descriptor is a class attribute.
Performance and Overhead
Every attribute access through a descriptor adds a method call. In tight loops, this overhead can be measurable, but it is usually negligible compared to the cost of the logic inside __get__ or __set__. If performance is critical, consider whether a simple attribute with validation in a setter method would be sufficient. Descriptors shine when the same behavior is reused across many classes or attributes.
A more subtle performance concern is that storing values in instance.__dict__ via the descriptor can slow down attribute lookup slightly because the descriptor's __get__ must do a dictionary lookup. For most applications, this is acceptable. If you need maximum speed, you might combine descriptors with __slots__, but that adds complexity.
Pitfalls and Edge Cases
One common mistake is forgetting to implement __set__ when you want a data descriptor. If you only implement __get__, you get a non-data descriptor, and instance assignments will shadow it. Another pitfall is using a shared mutable state in the descriptor itself; each instance should store its own value in instance.__dict__, not in a descriptor attribute.
Another edge case is deletion. If you implement __delete__, you must handle the case where the attribute does not exist. Also, be careful when using descriptors with inheritance: the descriptor is looked up on the class, so subclasses inherit it, but __set_name__ is called only for the class where the descriptor is defined. If you need per-class behavior, you may need to override __set_name__ in the descriptor to store the class name.
When to Choose a Descriptor Over Other Approaches
Python offers several ways to control attribute access: property, __getattr__, __setattr__, and descriptors. property is the simplest for a single attribute on one class. __getattr__ and __setattr__ intercept all attribute access on a class, but they are broad and can be hard to maintain. Descriptors are the right choice when you need to reuse the same logic across multiple attributes or classes, because they encapsulate the behavior in a single object.
For example, a descriptor that validates a value is between two limits can be reused on any number of attributes in different classes. A property would require duplicating the validation logic for each attribute. If you only need to handle one attribute, a property is often clearer. Descriptors also integrate well with the standard library; for instance, typing uses descriptors to implement ClassVar and Final.
A final consideration is maintainability. Descriptors introduce an indirection that can make code harder to follow if overused. Reserve them for cases where the abstraction pays off: repeated validation, caching, or cross-cutting attribute behavior. When in doubt, start with a property and refactor to a descriptor only when the duplication becomes evident.