If you have ever used @property in Python and wondered how it works its magic, the answer lies in descriptors. Descriptors are the underlying mechanism that makes property, staticmethod, classmethod, and even Django's ORM possible. Understanding this advanced concept will elevate your Python knowledge to a whole new level.

In this complete guide, you will learn what descriptors are, how to implement them, the differences between data and non-data descriptors, practical use cases, and how they apply in real-world frameworks. We will dive deep into the descriptor protocol and understand how Python manages attributes under the hood.

What Are Python Descriptors?

A descriptor is any Python object that implements at least one of the methods in the descriptor protocol: __get__, __set__, or __delete__. When a class attribute is a descriptor, Python redirects the default behavior of attribute access, assignment, and deletion to these special methods.

In practice, descriptors let you create attributes with custom behavior. Instead of simply storing and retrieving a value, you can run validation logic, transformations, caching, or any other operation whenever the attribute is accessed or modified.

According to the official Python descriptor HOWTO, this mechanism is one of the pillars of Python's object model, controlling how attributes are resolved in virtually every class you write.

To understand descriptors, we first need to revisit how Python resolves attributes. The __getattribute__ method is called on every attribute access, and it is where Python checks whether the attribute is a descriptor. Check the __getattribute__ documentation for a detailed walkthrough of this process.

The Descriptor Protocol

The descriptor protocol consists of three magic methods:

  • __get__(self, obj, objtype=None) — called when the attribute is read
  • __set__(self, obj, value) — called when the attribute is assigned
  • __delete__(self, obj) — called when the attribute is deleted with del

An object implementing __set__ or __delete__ is called a data descriptor. An object implementing only __get__ is called a non-data descriptor. This distinction is crucial for understanding Python's attribute resolution precedence.

Here is the simplest possible descriptor implementation:

class MyDescriptor:
    def __get__(self, obj, objtype=None):
        return 42

class MyClass: attr = MyDescriptor()

obj = MyClass() print(obj.attr) # 42

When we access obj.attr, Python calls MyDescriptor.__get__, which returns 42. That is the basic principle. The descriptors section of the Python data model covers every detail of the protocol.

Data Descriptors vs Non-Data Descriptors

The difference between data and non-data descriptors determines lookup precedence. When you access obj.attr, Python follows this order:

  1. Data descriptors defined on the object's class
  2. Instance attributes (the object's __dict__)
  3. Non-data descriptors and other class attributes

This means a data descriptor always takes priority over the instance dictionary. A non-data descriptor, on the other hand, can be "overridden" by an instance attribute with the same name.

class DataDescriptor:
    def __get__(self, obj, objtype=None):
        return "DATA DESCRIPTOR"
    def __set__(self, obj, value):
        print(f"Setting {value}")

class NonDataDescriptor: def get(self, obj, objtype=None): return "NON-DATA DESCRIPTOR"

class Test: data = DataDescriptor() non_data = NonDataDescriptor()

def __init__(self):
    self.data = "instance"      # Ignored! Data descriptor wins
    self.non_data = "instance"  # Works! Overrides non-data

t = Test() print(t.data) # "DATA DESCRIPTOR" print(t.non_data) # "instance"

The Real Python guide to descriptors explains this hierarchy with clear, didactic examples.

How Property Works Under the Hood

The built-in property function is the most well-known example of a data descriptor. It implements __get__, __set__, and __delete__ to connect user-defined getters, setters, and deleters.

You can recreate a simplified version of property to understand the mechanism:

class Property:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    if self.fget is None:
        raise AttributeError("unreadable attribute")
    return self.fget(obj)

def __set__(self, obj, value):
    if self.fset is None:
        raise AttributeError("can't set attribute")
    self.fset(obj, value)

def __delete__(self, obj):
    if self.fdel is None:
        raise AttributeError("can't delete attribute")
    self.fdel(obj)

class Person: def init(self, name): self._name = name

@Property
def name(self):
    return self._name

@name.setter
def name(self, value):
    self._name = value.upper()

The official property implementation in the standard library is more robust, but the principle is exactly this: a data descriptor that delegates calls to custom functions.

Practical Use Cases for Descriptors

Descriptors solve real problems elegantly. Let us explore practical applications you can use today.

1. Attribute Validation

Validating data on assignment is one of the most common descriptor use cases:

class Validated:
    def __init__(self, type, min=None, max=None):
        self.type = type
        self.min = min
        self.max = max
def __set_name__(self, owner, name):
    self.name = name

def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    return obj.__dict__.get(self.name)

def __set__(self, obj, value):
    if not isinstance(value, self.type):
        raise TypeError(f"{self.name} must be {self.type.__name__}")
    if self.min is not None and value < self.min:
        raise ValueError(f"{self.name} cannot be less than {self.min}")
    if self.max is not None and value > self.max:
        raise ValueError(f"{self.name} cannot be greater than {self.max}")
    obj.__dict__[self.name] = value

class Product: name = Validated(str, max=100) price = Validated(float, min=0)

def __init__(self, name, price):
    self.name = name
    self.price = price

p = Product("Laptop", 3500.0)

p.price = -10 # Raises ValueError

p.name = 123 # Raises TypeError

Notice the use of __set_name__, an optional descriptor protocol method introduced in Python 3.6. It is called when the class is created and tells the descriptor the attribute name it is bound to. The PEP 487 documents this feature in detail.

2. Lazy Loading and Cached Properties

Descriptors can defer expensive computation until the attribute is first accessed, caching the result for subsequent calls:

class LazyProperty:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__
def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    value = self.func(obj)
    obj.__dict__[self.name] = value  # Store in instance __dict__
    return value

class DataAnalysis: def init(self, data): self.data = data

@LazyProperty
def mean(self):
    print("Calculating mean...")
    return sum(self.data) / len(self.data)

@LazyProperty
def median(self):
    print("Calculating median...")
    sorted_data = sorted(self.data)
    n = len(sorted_data)
    mid = n // 2
    return sorted_data[mid] if n % 2 else (sorted_data[mid-1] + sorted_data[mid]) / 2

a = DataAnalysis([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) print(a.mean) # Calculating mean... 5.5 print(a.mean) # 5.5 (no recalculation!) print(a.median) # Calculating median... 5.5

This pattern is widely used in ORMs like SQLAlchemy and Django, where database queries are deferred until attribute access. Complement your studies with our guide on Object-Oriented Programming in Python to understand how descriptors fit into the broader OOP ecosystem.

3. Unit Descriptors (Measurements)

Descriptors allow you to create attributes with smart unit handling:

class Measurement:
    def __init__(self, unit):
        self.unit = unit
def __set_name__(self, owner, name):
    self.name = name

def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    return obj.__dict__.get(self.name)

def __set__(self, obj, value):
    if value < 0:
        raise ValueError(f"{self.name} cannot be negative")
    obj.__dict__[self.name] = f"{value} {self.unit}"

class Order: weight = Measurement("kg") height = Measurement("cm")

order = Order() order.weight = 5.5 order.height = 30 print(order.weight) # 5.5 kg print(order.height) # 30 cm

Python frameworks make heavy use of descriptors. Django's ORM, for instance, uses descriptors to implement model fields. When you write name = models.CharField(max_length=100), CharField is a descriptor that manages reading and writing the attribute, including validation and SQL conversion.

The Django model system is one of the most instructive examples of descriptors in production. Every field type (CharField, IntegerField, ForeignKey) implements the descriptor protocol to translate Python attributes into database columns.

SQLAlchemy also uses descriptors extensively. Mapped columns in a declarative class are descriptors that intercept access to load data from the database on demand (lazy loading) and track changes. The SQLAlchemy ORM tutorial shows how these descriptors work in practice.

Python's own standard library uses descriptors in several places. The @staticmethod and @classmethod decorators are descriptors that modify how methods are called. The official docs have a dedicated section on static and class methods as descriptors.

To dive deeper into how decorators relate to descriptors, check out our tutorial on Python Decorators — both concepts go hand in hand when designing elegant APIs.

Descriptors vs Property vs __getattr__

Python offers multiple ways to customize attribute access. Here is when to use each:

Mechanism Scope Recommended Use
Descriptor Specific attribute Reusability across classes, complex validation
Property Specific attribute Simple getter/setter, no reusability needed
__getattr__ All attributes Fallback for missing attributes
__getattribute__ All attributes Full control (use with caution)

Descriptors are the ideal choice when you need the same validated behavior across multiple classes or multiple attributes. If the logic is specific to a single attribute, @property is simpler. The Descriptor in Python article from GeeksforGeeks compares these approaches with practical examples.

Common Pitfalls and Best Practices

Descriptors are powerful but require care. Here are the most frequent mistakes:

1. Forgetting to Store in the Instance __dict__

A classic mistake is storing values on the descriptor itself (a class attribute) instead of the instance dictionary:

class WrongDescriptor:
    def __init__(self):
        self.value = None  # Bug: shared across instances!
def __get__(self, obj, objtype=None):
    return self.value

def __set__(self, obj, value):
    self.value = value  # All instances share the same value

Always store instance data in obj.__dict__, not in self. The descriptor itself is a class attribute and is shared by all instances.

2. Confusing Data and Non-Data Descriptors

Remember: if you implement __set__ or __delete__, your descriptor becomes a data descriptor and always takes precedence over instance attributes. This can be surprising if you only wanted a custom __get__.

3. Performance

Every descriptor-mediated attribute access has an additional function call overhead. For attributes accessed millions of times in inner loops, consider caching (as in the LazyProperty example) or using plain attributes.

The __setattr__ documentation clarifies how attribute assignment interacts with descriptors and the object lifecycle.

Conclusion

Descriptors are one of the most elegant and underappreciated concepts in Python. They form the foundation of property, staticmethod, classmethod, ORMs, validation systems, and much more. Mastering descriptors means truly understanding how Python's object model works.

Now you know how to implement your own descriptors for validation, lazy loading, caching, and unit-aware attributes. More importantly, you understand the descriptor protocol and how it fits into Python's attribute resolution hierarchy.

Practice by creating real descriptors: start with a type validator, evolve to lazy properties, and then explore more advanced patterns. The more you use descriptors, the more natural it will be to spot opportunities where they simplify your code.

References for further study: