Python Descriptors Explained with Practical Examples
python descriptors: Understand the descriptor protocol in Python, how __get__, __set__, and __delete__ control attribute access, and when to use descriptors over @prop...
When you access an attribute on an object, Python does more than look up a value in a dictionary. If the attribute's class defines certain methods, Python hands the access to those methods. That mechanism is the descriptor protocol, and it powers features like @property, classmethod, and staticmethod. Understanding python descriptors lets you build reusable attribute behavior that would otherwise require duplicating logic in every class.
How Python Invokes Descriptors
A descriptor is any object that implements at least one of __get__, __set__, or __delete__. When such an object is stored as a class attribute, Python intercepts attribute access on instances and routes it through those methods.
The protocol works as follows:
__get__(self, obj, objtype=None)is called when the attribute is read.__set__(self, obj, value)is called when the attribute is assigned.__delete__(self, obj)is called when the attribute is deleted withdel.
Python decides whether to call these methods based on the lookup order. For an instance attribute access, Python first checks the type's MRO for a data descriptor (one that defines __set__ or __delete__). If found, it calls that descriptor's __get__. Otherwise, it checks the instance's __dict__ first, then falls back to non-data descriptors in the class.
This ordering is why @property overrides an instance attribute of the same name, while a plain method (a non-data descriptor) can be shadowed by assigning to the instance.
Writing a Minimal Descriptor
A minimal descriptor is a class with __get__ and optionally __set__. Here is a simple descriptor that stores a value in the instance dictionary under a private key:
class PositiveNumber: def __set_name__(self, owner, name): self.private_name = "_" + name def __get__(self, obj, objtype=None): if obj is None: return self return getattr(obj, self.private_name, None) def __set__(self, obj, value): if value <= 0: raise ValueError("value must be positive") setattr(obj, self.private_name, value)
__set_name__ is called automatically when the descriptor is assigned in a class body. It lets the descriptor know the attribute name it is bound to, avoiding hardcoded names. The __get__ method returns the stored value, and __set__ validates before storing.
Use it in a class:
class Product: price = PositiveNumber() def __init__(self, price): self.price = price
Now Product(10) works, but Product(-5) raises ValueError. The validation lives in one place and applies to every instance.
Using Descriptors for Validation
Validation is the most common use case for descriptors. Instead of repeating checks in every setter, you can encapsulate the rule in a descriptor and reuse it across fields.
Consider a descriptor that enforces a type and a range:
class BoundedNumber: def __init__(self, min_value, max_value): self.min_value = min_value self.max_value = max_value def __set_name__(self, owner, name): self.private_name = "_" + name def __get__(self, obj, objtype=None): if obj is None: return self return getattr(obj, self.private_name) def __set__(self, obj, value): if not isinstance(value, (int, float)): raise TypeError("value must be numeric") if not (self.min_value <= value <= self.max_value): raise ValueError(f"value must be between {self.min_value} and {self.max_value}") setattr(obj, self.private_name, value)
This descriptor can be applied to multiple attributes in different classes. The validation logic is defined once and reused, which reduces duplication compared to writing separate @property setters for each field.
Descriptors for Caching and Computed Attributes
Another practical use is lazy evaluation. A descriptor can compute a value on first access and cache it, avoiding repeated expensive calculations.
class CachedProperty: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, obj, objtype=None): if obj is None: return self value = self.func(obj) setattr(obj, self.name, value) return value
This descriptor wraps a method and replaces the attribute with the computed value on first access. Subsequent reads find the cached value in the instance dictionary and skip the computation.
class Report: def __init__(self, data): self.data = data @CachedProperty def summary(self): # Expensive aggregation return sum(self.data)
Note that CachedProperty is a non-data descriptor because it only defines __get__. That means assigning to instance.summary will overwrite the cached value, which is often acceptable for a one-time computation. If you need to prevent overwriting, add a __set__ that raises an error.
The Difference Between Data and Non-Data Descriptors
A data descriptor defines __set__ or __delete__. A non-data descriptor defines only __get__. This distinction changes attribute lookup precedence.
For a data descriptor, Python always calls the descriptor's __get__ before checking the instance dictionary. For a non-data descriptor, the instance dictionary is checked first, so an instance attribute can shadow the descriptor.
This behavior is why @property (which is a data descriptor) cannot be overridden by setting instance.attr = value; the setter is invoked instead. In contrast, a plain method (non-data descriptor) can be replaced per instance by assigning a new function to the instance's __dict__.
Understanding this difference is critical when designing descriptors that must control both read and write access. If you only implement __get__, you lose control over assignment.
Performance and Overhead Considerations
Descriptors introduce a function call for every attribute access that goes through them. Compared to a plain attribute lookup, this adds overhead. In performance-sensitive code, measure whether the flexibility is worth the cost.
For simple cases, @property is implemented using descriptors and has similar overhead. If you need the same behavior on many attributes, a single descriptor class can be more maintainable than multiple property definitions, even if each access is slightly slower.
There is also a memory consideration: each descriptor instance is stored as a class attribute, and each instance stores its own value in __dict__. This is the same memory profile as a normal attribute; the descriptor itself is shared across instances.
Avoid using descriptors for trivial attribute access where no validation or transformation is needed. A plain attribute is faster and simpler.
Common Pitfalls and Compatibility Notes
One common mistake is forgetting to handle obj is None in __get__. When you access the descriptor from the class itself, Python passes None as obj. Without a guard, code that does Product.price will fail. Always return the descriptor itself in that case, as shown in earlier examples.
Another pitfall is relying on the descriptor's own __dict__ to store per-instance state. Descriptors are shared across all instances, so storing instance-specific data there would leak between objects. Use the instance's dictionary via getattr/setattr with a unique key.
Python version compatibility matters. __set_name__ was introduced in Python 3.6. If you need to support older versions, you must pass the attribute name explicitly to the descriptor's constructor. Also, the descriptor protocol itself is stable across Python 3.x, but the exact lookup rules are part of the language specification and have not changed.
When to Reach for Descriptors
Descriptors are the right tool when you need reusable attribute behavior that applies to multiple classes or multiple fields within a class. They shine for validation, type checking, transformation, and caching.
If you only need to control one attribute in one class, @property is simpler and more direct. Descriptors add indirection that may not be justified for a single use.
Use a descriptor when:
- The same logic must be applied to several attributes, possibly across different classes.
- The behavior is complex enough that separating it into a dedicated class improves readability.
- You need to implement a protocol that requires a descriptor, such as building a custom
propertyorclassmethod.
Avoid descriptors when the logic is trivial and unlikely to be reused. A plain attribute or a simple @property keeps the code easier to follow.
Descriptors are a low-level feature that underlies much of Python's object model. Once you understand the protocol, you can build abstractions that behave like native language features, giving you precise control over attribute access without sacrificing clarity.