Properties and descriptors both control attribute access in Python. A property is a concise way to define getter, setter, and deleter behaviour for a single attribute on a single class, while a descriptor is a reusable class that can be applied to many attributes across many classes. Both are built on the same descriptor protocol.
The article on Python descriptors explained the descriptor protocol in depth. This article compares the two approaches side by side so you can choose the right tool for each situation.
When you write an at-property decorator, Python creates a descriptor object under the hood. The function you define becomes the get method of that descriptor, and the setter and deleter you attach become the set and delete methods. This means every property is a descriptor, but not every descriptor is a property.
How properties work under the hood
A property turns method calls into attribute-like access. The classic use case is adding validation to an attribute without changing the public API that consumers of the class already use.
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
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius)
print(t.fahrenheit)
t.celsius = 100
print(t.fahrenheit)The celsius property stores data in a private attribute and validates on write. The fahrenheit property is read-only and computes its value from celsius.
25
77.0
212.0Properties work well here because the logic is specific to the Temperature class. There is no need to reuse the celsius validation on a different class, and the fahrenheit computation is unique to this domain.
When properties become painful
Properties work less well when the same pattern repeats across multiple attributes. Imagine you need validated attributes on several classes. Writing the same getter-setter pattern for each one creates duplication.
class Product:
def __init__(self, name, price):
self._name = name
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)):
raise TypeError("price must be numeric")
if value < 0:
raise ValueError("price cannot be negative")
self._price = valueThe price property alone already requires significant boilerplate for one attribute. Adding validation for a name attribute on another class means writing nearly the same structure again.
class Item:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not isinstance(value, str):
raise TypeError("name must be a string")
if not value.strip():
raise ValueError("name cannot be empty")
self._name = valueThe getter-setter shape is nearly identical to the price property, just applied to a different attribute and class. Multiply this across several classes and the duplication adds up fast.
Replacing repeated properties with a descriptor
When the validation logic is the same shape for many attributes, a descriptor class eliminates the duplication. You define the validation once and reuse it by instantiating the descriptor on each class attribute.
class Validated:
def __init__(self, validator):
self.validator = validator
self.storage = {}
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return self.storage.get(id(instance))
def __set__(self, instance, value):
self.validator(self.name, value)
self.storage[id(instance)] = valueThe set_name method, available since Python 3.6, lets the descriptor discover its own attribute name without requiring it to be passed explicitly. Keying storage by id(instance) works for this example, but production code should prefer a WeakKeyDictionary keyed by the instance itself, since plain ids can be reused once an instance is garbage collected.
Now the Product class becomes much cleaner. The validation functions are defined separately and reused wherever needed.
def check_positive(name, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{name} must be numeric")
if value < 0:
raise ValueError(f"{name} cannot be negative")
def check_nonempty(name, value):
if not isinstance(value, str):
raise TypeError(f"{name} must be a string")
if not value.strip():
raise ValueError(f"{name} cannot be empty")
class Product:
name = Validated(check_nonempty)
price = Validated(check_positive)
p = Product()
p.name = "Widget"
p.price = 29
print(p.name, p.price)The class body now declares what each attribute needs rather than how validation is implemented. Adding a third validated attribute is a single line.
Widget 29Choosing between properties and descriptors
Use a property when the behaviour is specific to one attribute on one class. A computed attribute like full_name from first_name and last_name belongs in a property. Validation that only applies to one field in one context also fits a property.
Use a descriptor when the same behaviour applies to many attributes, possibly across many classes. Type checking, range validation, unit conversion, lazy loading, and logging are all patterns that benefit from descriptors.
The article on customizing Python objects with magic methods covers the setattr approach, which is a third option. The setattr method on the class itself gives you attribute-level control without descriptors, at the cost of making every assignment go through a single method regardless of which attribute is being set.
Practical guidelines for migration
When you notice yourself writing the same property pattern three or more times in a codebase, consider extracting a descriptor. The refactoring is straightforward: move the getter and setter logic into a descriptor class, replace the property definitions with descriptor instances, and delete the duplicate code.
Do not preemptively use descriptors for attributes that might need validation someday. A property is easier to read and debug. You can always upgrade to a descriptor later when the duplication becomes real.
When using descriptors, store per-instance data in a WeakKeyDictionary keyed by the instance, or in the instance's own __dict__ under a private name. Avoid storing data directly on the descriptor object, because a single descriptor instance is shared across all instances of the class.
Rune AI
Key Insights
- @property is concise for single-attribute computed values and validation within one class.
- Descriptor classes are reusable across multiple classes and attributes.
- Properties are data descriptors; they take priority over instance dictionaries.
- Choose properties for simplicity; choose descriptors when you find yourself copying the same property pattern.
- Both properties and descriptors follow the same data descriptor priority rules during attribute lookup.
Frequently Asked Questions
When should I use @property instead of a descriptor?
Are Python properties implemented as descriptors?
Conclusion
Properties and descriptors solve the same problem at different scales. A property is the right tool for one attribute in one class. A descriptor is the right tool when the same logic applies to many attributes across many classes. Knowing both lets you pick the simplest solution for each situation rather than forcing one pattern everywhere.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.