Properties as Descriptors¶
The key mental model for this page: property is just a specialized descriptor. Everything you can do with @property --- getters, setters, deleters --- is implemented via the descriptor protocol (__get__, __set__, __delete__). Understanding this connection lets you build your own reusable property-like tools. This page builds on Properties.
The Connection¶
1. Property IS a Descriptor¶
The @property decorator creates a descriptor object:
```python class Example: @property def value(self): return self._value
Property is a descriptor!¶
print(type(Example.value)) #
2. How Property Works¶
```python
When you write:¶
@property def value(self): return self._value
Python creates:¶
value = property(fget=value_getter_function)
Which is a descriptor with get, set, delete¶
```
3. Accessing Property¶
python
obj.value
↓
type(obj).__dict__['value'].__get__(obj, type(obj))
↓
Calls the getter function
Property Implementation¶
1. Simplified Property¶
```python class Property: """Simplified property implementation""" def init(self, fget=None, fset=None, fdel=None): self.fget = fget self.fset = fset self.fdel = fdel
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(instance)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel)
class Circle: def init(self, radius): self._radius = radius
@Property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be positive")
self._radius = value
c = Circle(5) print(c.radius) # 5 c.radius = 10 print(c.radius) # 10 ```
2. Property Data Descriptor¶
```python class Example: @property def value(self): return self._value
@value.setter
def value(self, val):
self._value = val
Has both get and set → data descriptor¶
desc = type(Example.value) print(hasattr(Example.value, 'get')) # True print(hasattr(Example.value, 'set')) # True
Data descriptor wins over instance dict¶
obj = Example() obj.dict['value'] = 999 obj.value = 42 print(obj.value) # 42 (not 999) ```
3. Read-Only Property¶
```python class Example: @property def value(self): return self._value
Only has get → non-data descriptor¶
print(hasattr(Example.value, 'get')) # True print(hasattr(Example.value, 'set')) # False
Can be overridden by instance dict¶
obj = Example() obj._value = 42 obj.dict['value'] = 999 print(obj.value) # 999 (instance dict wins) ```
Custom Property-Like Descriptors¶
1. Validated Property¶
```python class ValidatedProperty: def init(self, validator): self.validator = validator self.name = None
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, None)
def __set__(self, instance, value):
if not self.validator(value):
raise ValueError(f"Validation failed for {self.name}")
setattr(instance, self.name, value)
class Person: age = ValidatedProperty(lambda x: 0 <= x <= 150) name = ValidatedProperty(lambda x: len(x) > 0)
p = Person() p.age = 30 # ✅ OK
p.age = 200 # ❌ ValueError¶
```
2. Computed Property with Caching¶
```python class CachedProperty: def init(self, func): self.func = func self.name = func.name
def __get__(self, instance, owner):
if instance is None:
return self
# Check cache in instance dict
value = instance.__dict__.get(self.name)
if value is None:
# Compute and cache
value = self.func(instance)
instance.__dict__[self.name] = value
return value
def __set__(self, instance, value):
# Allow manual override
instance.__dict__[self.name] = value
def __delete__(self, instance):
# Clear cache
instance.__dict__.pop(self.name, None)
class DataLoader: @CachedProperty def data(self): print("Loading data...") return [1, 2, 3, 4, 5]
loader = DataLoader() print(loader.data) # Loading data... [1, 2, 3, 4, 5] print(loader.data) # [1, 2, 3, 4, 5] (cached) del loader.data print(loader.data) # Loading data... [1, 2, 3, 4, 5] ```
3. Type-Enforced Property¶
```python class TypedProperty: def init(self, expected_type): self.expected_type = expected_type self.name = None
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, None)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name[1:]} must be {self.expected_type.__name__}"
)
setattr(instance, self.name, value)
class Product: name = TypedProperty(str) price = TypedProperty((int, float)) quantity = TypedProperty(int)
p = Product() p.name = "Widget" # ✅ OK p.price = 19.99 # ✅ OK
p.name = 123 # ❌ TypeError¶
```
Property vs Custom Descriptor¶
1. When to Use Property¶
Use @property when:
- Simple getter/setter/deleter logic
- One-off attribute management
- Quick prototyping
- Standard property behavior is sufficient
python
class Circle:
@property
def area(self):
return 3.14 * self.radius ** 2
2. When to Use Custom Descriptor¶
Use custom descriptor when:
- Reusable validation logic
- Complex attribute behavior
- Need to share logic across multiple classes
- Building frameworks or libraries
```python class PositiveNumber: def set(self, instance, value): if value <= 0: raise ValueError("Must be positive") instance.dict[self.name] = value
class Rectangle: width = PositiveNumber() height = PositiveNumber()
class Circle: radius = PositiveNumber() ```
3. Comparison Table¶
| Aspect | @property |
Custom Descriptor |
|---|---|---|
| Syntax | Decorator | Class definition |
| Reusability | Per class | Across classes |
| Complexity | Simple | Can be complex |
| Use case | Specific attributes | Generic patterns |
| Examples | Computed values | Validation, ORM fields |
Advanced Patterns¶
1. Chained Properties¶
```python class ChainedProperty: def init(self, func): self.func = func
def __get__(self, instance, owner):
if instance is None:
return self
return self.func(instance)
def __set__(self, instance, value):
# Allow chaining
self.func(instance).__set__(value)
return instance
class Builder: @ChainedProperty def name(self): return self
b = Builder().name("Alice").name("Bob") ```
2. Property with Dependencies¶
```python class DependentProperty: def init(self, func, *dependencies): self.func = func self.dependencies = dependencies self.name = None
def __set_name__(self, owner, name):
self.name = f'_cached_{name}'
def __get__(self, instance, owner):
if instance is None:
return self
# Check if dependencies changed
cache_key = tuple(getattr(instance, d) for d in self.dependencies)
cached = getattr(instance, self.name, None)
if cached is None or cached[0] != cache_key:
value = self.func(instance)
setattr(instance, self.name, (cache_key, value))
return cached[1] if cached else self.func(instance)
class Rectangle: def init(self, width, height): self.width = width self.height = height
@DependentProperty
def area(self):
print("Computing area")
return self.width * self.height
area.dependencies = ('width', 'height')
r = Rectangle(5, 10) print(r.area) # Computing area... 50 r.width = 6 print(r.area) # Computing area... 60 (recomputed) ```
3. Lazy Property¶
```python class LazyProperty: def init(self, func): self.func = func self.name = func.name
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
# Replace descriptor with computed value
setattr(instance, self.name, value)
return value
class DataLoader: @LazyProperty def data(self): print("Loading...") return [1, 2, 3, 4, 5]
loader = DataLoader()
print(type(loader.class.data)) #
Internals¶
1. Property Object Structure¶
```python class Example: @property def value(self): """Get value""" return self._value
prop = Example.value
print(prop.fget) #
2. Decorator Chain¶
```python class Example: @property def value(self): return self._value
@value.setter
def value(self, val):
self._value = val
Is equivalent to:¶
def get_value(self): return self._value
def set_value(self, val): self._value = val
value = property(get_value) value = value.setter(set_value) ```
3. Manual Property Creation¶
```python class Example: def _get_value(self): return self._value
def _set_value(self, val):
if val < 0:
raise ValueError("Must be non-negative")
self._value = val
def _del_value(self):
del self._value
value = property(_get_value, _set_value, _del_value, "Value property")
obj = Example() obj.value = 42 print(obj.value) # 42 ```
Summary¶
1. Key Takeaways¶
- Properties ARE descriptors (specifically, data descriptors when they have a setter)
- Properties use
__get__,__set__, and__delete__under the hood - Custom descriptors allow reusable property-like behavior
- Choose properties for simplicity, descriptors for reusability
2. Mental Model¶
python
@property Custom Descriptor
↓ ↓
Creates property object Creates descriptor class
↓ ↓
Descriptor protocol Descriptor protocol
↓ ↓
__get__, __set__, __delete__
3. Best Practices¶
- Use
@propertyas default choice - Create custom descriptors when reusing logic
- Document descriptor behavior clearly
- Test edge cases (class access, None checks)
- Consider using
__set_name__for automatic naming
The Unifying View
There are no "properties" in Python — there is only attribute access + the descriptor protocol. When you write obj.attr, Python checks the class for a descriptor first. If found, the descriptor's __get__/__set__/__delete__ methods control access. @property, cached_property, methods, __slots__, and custom descriptors are all variations of this single mechanism. Properties are not special syntax — they are part of attribute lookup.
Exercises¶
Exercise 1. Implement a simplified Property descriptor class with __get__ and __set__ methods that mimics the behavior of Python's built-in property. Test it by creating a Circle class with a radius managed by your custom descriptor that rejects negative values.
Solution to Exercise 1
```python class Property: def init(self, fget=None, fset=None): self.fget = fget self.fset = fset
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def setter(self, fset):
return Property(self.fget, fset)
class Circle: def init(self, radius): self.radius = radius
@Property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be non-negative")
self._radius = value
c = Circle(5) print(c.radius) # 5
c.radius = 10 print(c.radius) # 10
try: c.radius = -1 except ValueError as e: print(e) # Radius must be non-negative ```
Exercise 2. Predict the output:
```python class MyProp: 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, None)
def __set__(self, instance, value):
setattr(instance, self.name, value)
class Demo: x = MyProp()
d = Demo() d.x = 42 print(d.x) print(d.dict) ```
Solution to Exercise 2
The output is:
42
{'_x': 42}
The __set_name__ method sets self.name to "_x". When d.x = 42 is called, the descriptor's __set__ stores the value via setattr(instance, '_x', 42), placing _x in the instance dictionary. The __get__ method retrieves it via getattr(instance, '_x').
Exercise 3. Create a reusable TypedProperty descriptor that enforces a specific type. Use it to build a Product class where name must be a str and price must be a float or int. Demonstrate that assigning a wrong type raises TypeError.
Solution to Exercise 3
```python class TypedProperty: def init(self, expected_type): self.expected_type = expected_type
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, None)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"Expected {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(instance, self.name, value)
class Product: name = TypedProperty(str) price = TypedProperty((int, float))
p = Product() p.name = "Widget" p.price = 19.99 print(p.name, p.price) # Widget 19.99
try: p.name = 123 except TypeError as e: print(e) # Expected str, got int ```
Exercise 4. Explain why a property with a setter is a "data descriptor" and why this matters for attribute lookup. Write a short example showing that a data descriptor takes priority over an entry in instance.__dict__.
Solution to Exercise 4
A data descriptor defines both __get__ and __set__ (or __delete__). Python's attribute lookup gives data descriptors priority over instance __dict__ entries.
```python class DataDesc: def get(self, instance, owner): if instance is None: return self return "from descriptor"
def __set__(self, instance, value):
print(f"Descriptor intercepted: {value}")
class Example: attr = DataDesc()
obj = Example() obj.dict['attr'] = "from instance dict" print(obj.attr) # "from descriptor" — descriptor wins obj.attr = 99 # "Descriptor intercepted: 99" ```
Because the descriptor defines __set__, it is a data descriptor and always takes priority over any same-named key in instance.__dict__.
Exercise 5. Build a ValidatedProperty descriptor that accepts a validator function. Use __set_name__ for automatic naming. Apply it to a Person class where age must be between 0 and 150, and name must be a non-empty string.
Solution to Exercise 5
```python class ValidatedProperty: def init(self, validator): self.validator = validator
def __set_name__(self, owner, name):
self.name = f"_{name}"
self.public_name = name
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name, None)
def __set__(self, instance, value):
if not self.validator(value):
raise ValueError(
f"Validation failed for '{self.public_name}': {value!r}"
)
setattr(instance, self.name, value)
class Person: age = ValidatedProperty(lambda x: isinstance(x, int) and 0 <= x <= 150) name = ValidatedProperty(lambda x: isinstance(x, str) and len(x.strip()) > 0)
p = Person() p.name = "Alice" p.age = 30 print(p.name, p.age) # Alice 30
try: p.age = 200 except ValueError as e: print(e) # Validation failed for 'age': 200 ```