The Power of Descriptors: Going Beyond @property
By hientd, at: April 1, 2025, 9:17 p.m.
Estimated Reading Time: __READING_TIME__ minutes


<meta charset="UTF-8">
Python @property
decorator is beloved for clean attribute control. But when you need reusable validation, dynamic behavior, or deeper control over attribute access, it’s time to turn to something more powerful: descriptors.
Descriptors are at the heart of many core Python features and they’re surprisingly easy to use once you understand them.
What Is a Descriptor?
A descriptor is any object that defines one or more of the following methods:
-
__get__(self, instance, owner)
-
__set__(self, instance, value)
-
__delete__(self, instance)
Python uses descriptors behind the scenes for @property, methods, and even class attributes.
Deeper Dive into __get__
, __set__
, and __delete__
__get__(self, instance, owner)
-
instance: the actual object (obj)
-
owner: the class where the descriptor is accessed (ObjClass)
class ClassCounter:
count = 0
def __get__(self, instance, owner):
if instance is None:
return owner.count # Access via class
return instance._value
def __set__(self, instance, value):
instance._value = value
instance.__class__.count += 1
class MyClass:
counter = ClassCounter()
def __init__(self, val):
self.counter = val
print(MyClass.counter) # Outputs 0
obj1 = MyClass(10)
obj2 = MyClass(20)
print(obj1.counter) # Outputs 10
print(MyClass.count) # Outputs 2
Return Values:
-
__get__
: returns the attribute value.
-
__set__, __delete__
: typically return None.
Reusable Validation Logic
Instead of rewriting @property
everywhere, use descriptors:
class PositiveInteger:
def __init__(self, name):
self.name = f"_{name}"
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if not isinstance(value, int) or value < 0:
raise ValueError("Must be a non-negative integer")
setattr(instance, self.name, value)
class Product:
price = PositiveInteger("price")
stock = PositiveInteger("stock")
Lazy Loading
Descriptors can delay computation or expensive operations until first access.
import time
class LazyLoad:
def __init__(self, func):
self.func = func
self._cache = {}
def __get__(self, instance, owner):
if instance is None:
return self
if instance not in self._cache:
print(f"Loading {self.func.__name__}...")
time.sleep(2)
self._cache[instance] = self.func(instance)
return self._cache[instance]
class DataContainer:
@LazyLoad
def expensive_data(self):
return list(range(10))
container = DataContainer()
print("First:", container.expensive_data)
print("Second:", container.expensive_data)
Type Checking with Descriptors
class Typed:
def __init__(self, name, expected_type):
self.name = f"_{name}"
self.expected_type = expected_type
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be of type {self.expected_type.__name__}")
setattr(instance, self.name, value)
class Model:
name = Typed("name", str)
age = Typed("age", int)
Read-Only Attributes
class ReadOnly:
def __init__(self, name, value):
self.name = f"_{name}"
self.value = value
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
raise AttributeError(f"'{self.name[1:]}' is read-only")
class SystemInfo:
version = ReadOnly("version", "1.0")
Attribute Transformation
class LowercaseString:
def __init__(self, name):
self.name = f"_{name}"
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
setattr(instance, self.name, value.lower())
class Settings:
username = LowercaseString("username")
Performance Considerations
Yes, descriptors add indirection. But in most cases:
-
The performance hit is minimal.
-
The benefits of clean logic and reusability outweigh the cost.
-
Profile only when there’s real impact (use cProfile, line_profiler).
Avoid premature optimization, use descriptors where they bring value.
Inheritance with Descriptors
Descriptors defined in base classes are inherited automatically.
class NonNegative:
def __init__(self, name):
self.name = f"_{name}"
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name} cannot be negative")
setattr(instance, self.name, value)
class Shape:
area = NonNegative("area")
class Rectangle(Shape):
def __init__(self, area):
self.area = area
class Circle(Shape):
radius = NonNegative("radius")
def __init__(self, radius):
self.radius = radius
self.area = radius * radius * 3.14
Real-World Descriptor Use
-
Django ORM: Model fields use descriptors to control database access.
-
SQLAlchemy: Uses descriptors for attribute tracking and validation.
-
Pydantic: Uses descriptors for validation and coercion.
-
functools.
@cached_property
: A descriptor that caches computed values.
Best Practices
-
Use descriptors when the same logic appears across classes.
-
Always document what a descriptor does.
-
Don’t abuse descriptors, sometimes
@property
or simple functions are enough.
-
Choose clarity: good naming and structure are key.
A Teaser: Descriptors + Metaclasses
Want even more control?
Metaclasses + descriptors = 🔥. You can:
-
Auto-apply descriptors across classes
-
Enforce descriptor usage rules
-
Build mini-frameworks or DSLs (domain-specific languages)
More on that in a follow-up post
Final Thoughts
Descriptors unlock a powerful level of control over attribute access, validation, and behavior. Once you get comfortable with __get__
, __set__
, and __delete__
, you’ll never look at @property
the same way again.
They’re the unsung heroes behind some of the best Python libraries and now, maybe behind your code too.