The Power of Descriptors: Going Beyond @property

By hientd, at: April 1, 2025, 9:17 p.m.

Estimated Reading Time: __READING_TIME__ minutes

The Power of Descriptors: Going Beyond @property
The Power of Descriptors: Going Beyond @property

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

Tag list:
- Python descriptors tutorial
- Python descriptor __get__
- lazy loading attribute Python
- type checking with descriptors
- Django field descriptors
- reusable validation Python
- read-only attribute Python

Subscribe

Subscribe to our newsletter and never miss out lastest news.