Python Descriptor vs Property: Choosing the Right Attribute Mechanism
python descriptor vs property: Compare Python descriptors and property: how they work, when to use each, and how property is built on the descriptor protocol.
python descriptor vs property requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In Python, attribute access is not always a simple dictionary lookup. When you need validation, computed values, or lazy loading, you often reach for the @property decorator. But property is only one instance of a more general mechanism: the descriptor protocol. Understanding the difference between a descriptor and a property helps you decide when the built-in property is enough and when you need to implement a custom descriptor.
The Descriptor Protocol in Practice
A descriptor is any object that defines at least one of __get__, __set__, or __delete__. When you access an attribute on an instance, Python's attribute lookup mechanism checks whether the class has a descriptor with that name. If it does, the descriptor's methods take over the access.
class PositiveNumber: def __get__(self, instance, owner): return instance._value def __set__(self, instance, value): if value < 0: raise ValueError("Value must be positive") instance._value = value
This descriptor can be used on a class:
class Order: quantity = PositiveNumber()
When you write order.quantity = 5, Python calls PositiveNumber.__set__(order, 5). When you read order.quantity, it calls __get__. The descriptor is a class attribute, but it intercepts access on instances.
Descriptors are classified as data descriptors if they define __set__ or __delete__, and non-data descriptors if they only define __get__. Data descriptors take precedence over the instance's __dict__, while non-data descriptors do not. This distinction affects how attribute shadowing behaves.
How Property Uses the Descriptor Protocol
The property class is itself a descriptor. It implements __get__, __set__, and __delete__ internally, and it stores the functions you pass to its constructor. When you write:
class Temperature: def __init__(self, celsius): self._celsius = celsius @property def fahrenheit(self): return self._celsius * 9 / 5 + 32
fahrenheit is a property object. Accessing temp.fahrenheit invokes the getter function. Assigning to it would invoke the setter if you defined one. The property object is a data descriptor, so it takes precedence over the instance's __dict__.
You can think of property as a specialized descriptor that delegates to a single getter, setter, and deleter function for one attribute. It is implemented in C, which makes it fast, but it is limited to the behavior you can express in those functions.
Key Differences Between Descriptor and Property
| Criterion | Property | Custom Descriptor |
|---|---|---|
| Scope | One attribute per property object | Reusable across many attributes |
| Logic reuse | Requires repeating code for each attribute | Centralizes logic in one class |
| State storage | Typically stores value in _name attribute | Can store state in instance or descriptor |
__set_name__ support | Not directly available | Can automatically capture attribute name |
| Implementation | Built-in, C-level | Pure Python, more flexible |
| Overhead | Minimal | Slight function call overhead |
The most important difference is reuse. A property is bound to a single attribute name. If you need the same validation logic on five fields, you either write five property blocks or use a descriptor once and assign it to five names.
When a Custom Descriptor Is the Better Choice
Custom descriptors shine when the logic is identical across multiple attributes. Common use cases include:
- Validation: ensuring values fall within a range, match a pattern, or satisfy a type constraint.
- Caching: storing computed values and invalidating them when dependencies change.
- Lazy loading: deferring expensive computation until the attribute is accessed.
- Cross-attribute constraints: checking that one value is consistent with another.
For example, a PositiveNumber descriptor can be applied to price, quantity, and tax_rate in the same class, and the validation logic lives in one place. If you used property, you would repeat the same if value < 0 check in three getters and setters.
Another advantage is the __set_name__ hook, which lets the descriptor know the attribute name it is assigned to. This is useful for storing state in a predictable key:
class Validated: def __set_name__(self, owner, name): self.name = f"_{name}" def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.name) def __set__(self, instance, value): # validation logic setattr(instance, self.name, value) ```n``` `property` does not provide this hook, so you must manually choose a storage attribute name. ## Building a Reusable Validation Descriptor Consider a class that needs to validate several integer fields as non-negative. A descriptor can encapsulate the rule once: ```python class NonNegative: def __set_name__(self, owner, name): self.name = f"_{name}" def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.name) def __set__(self, instance, value): if value < 0: raise ValueError(f"{self.name} must be non-negative") setattr(instance, self.name, value) class Order: price = NonNegative() quantity = NonNegative() discount = NonNegative()
Now order.price = -5 raises ValueError, and the same rule applies to quantity and discount. With property, you would need three separate property definitions, each repeating the check.
This descriptor also handles __set_name__ automatically, so you do not have to write self._price = ... in __init__. The descriptor stores the value in an attribute named after the class attribute, prefixed with an underscore.
Performance and Maintainability Considerations
Every attribute access that goes through a descriptor incurs a function call. property is implemented in C, so its overhead is lower than a pure Python descriptor. However, for most applications, the difference is negligible unless you access the attribute millions of times in a tight loop.
Custom descriptors add indirection, but they also reduce duplication. The maintainability gain often outweighs the micro-performance cost. If you find yourself copying the same getter and setter logic across many fields, a descriptor is the DRY approach.
One performance nuance: non-data descriptors (only __get__) are not invoked if the instance already has an attribute with the same name in its __dict__. Data descriptors always take precedence. This means you can accidentally shadow a non-data descriptor by assigning to the instance attribute directly, which can be a source of subtle bugs.
Common Pitfalls and Runtime Behavior
A frequent mistake is assuming a descriptor works when assigned to an instance. Descriptors must be class attributes. If you write self.attr = SomeDescriptor() inside __init__, the descriptor protocol is not triggered; you are just storing the descriptor object as an instance attribute.
Another pitfall is forgetting that property is a data descriptor. If you define a property and also assign to the same name in __init__, the property's setter is called, not the instance dictionary. This is usually what you want, but it can surprise developers who expect a plain attribute.
Inheritance also affects descriptors. If a parent class defines a descriptor and a child class overrides the attribute name with a new descriptor, the child's descriptor takes precedence for instances of the child. The parent's descriptor is still accessible on parent instances.
When implementing __get__, the instance parameter is None when the descriptor is accessed from the class itself. This is common when you want to allow MyClass.attr to return the descriptor object for introspection. If you do not handle this case, your descriptor may raise AttributeError or return the wrong value when accessed on the class.
Choosing Between Descriptor and Property
Use property when the logic is specific to one attribute and you do not need to reuse it elsewhere. It is the simplest, most readable way to add a getter, setter, or deleter to a single field. For example, a computed property like area that depends on width and height is a perfect fit.
Use a custom descriptor when you find yourself repeating the same access logic across multiple attributes. The descriptor centralizes the behavior, reduces code duplication, and can leverage __set_name__ to avoid manual state management. If you need cross-attribute validation or a reusable pattern like a cached property, a descriptor gives you the flexibility to implement it once and apply it anywhere.
There is no hard rule that forces you to choose one exclusively. Many classes mix both: property for one-off computed values and descriptors for repeated validation. The key is to recognize that property is a specialized descriptor, and the descriptor protocol is the underlying machinery that gives you full control over attribute access.