Python Properties vs Descriptors

Compare Python properties and descriptors: when to use @property for simple cases and when to reach for a full descriptor class for reusable attribute management.

8 min read

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.

pythonpython
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.

texttext
25
77.0
212.0

Properties 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.

pythonpython
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 = value

The 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.

pythonpython
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 = value

The 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.

pythonpython
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)] = value

The 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.

pythonpython
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.

texttext
Widget 29

Choosing 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

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.
RunePowered by Rune AI

Frequently Asked Questions

When should I use @property instead of a descriptor?

Use @property when you need computed attributes or validation on a single attribute within a single class. Properties are concise, readable, and require no separate descriptor class. Use a descriptor when the same attribute behaviour needs to be reused across multiple classes, or when you need to manage storage independently from the class definition.

Are Python properties implemented as descriptors?

Yes. The @property decorator creates a descriptor object behind the scenes. The getter, setter, and deleter functions you provide become the __get__, __set__, and __delete__ methods of a built-in property descriptor. This means properties follow the same lookup rules and priority as any other data descriptor.

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.