Skip to main content

Command Palette

Search for a command to run...

Descriptors in Python — The Ultimate Guide

Updated
5 min read
Descriptors in Python — The Ultimate Guide

Introduction

In Python, everything is an object, and attribute access is a fundamental operation. But what if you wanted to control how an attribute behaves when accessed, modified, or deleted? That’s where descriptors come in. They’re a powerful, often overlooked feature that let you manage attribute access dynamically within a class.

This guide provides a complete overview of descriptors, including how they work, when to use them, and best practices for clean, reusable implementations.

What Are Descriptors?

A descriptor is a Python object that implements one or more of the following special methods:

  • __get__(self, instance, owner): Called when the attribute is accessed.

  • __set__(self, instance, value): Called when the attribute is assigned a new value.

  • __delete__(self, instance): Called when the attribute is deleted with del.

How Attribute Lookup Works

When an attribute is accessed in Python, the interpreter follows a specific lookup order:

  1. Instance dictionary (__dict__) → If the attribute exists in the instance, Python retrieves it directly.

  2. Class attributes → If not found in the instance, Python checks the class definition for the attribute.

  3. Descriptors → If the attribute is a descriptor, Python invokes its special methods (__get__, __set__, __delete__).

  4. Inheritance chain → If still unresolved, Python searches in parent classes.

  5. Fallback → If the attribute is missing everywhere, Python raises an AttributeError.

Thus, descriptors override instance attributes under certain conditions, making them extremely powerful for fine-grained control over attribute behavior.


Why Use Descriptors?

Descriptors provide precise control over attribute access, enabling:

  • Validation and type-checking → Ensure values meet specific criteria.

  • Computed properties → Define attributes that calculate values dynamically.

  • Resource management → Handle opening/closing connections, caching values.

  • Access control → Implement authentication and logging for sensitive attributes.

  • Lazy evaluation → Defer expensive computations until needed.

Built-in Python Features Using Descriptors

Several core Python features use descriptors internally:

  • property() → Implements computed attributes.

  • staticmethod() & classmethod() → Modify method behavior at the class level.

  • functools.cached_property → Implements efficient caching.

The Descriptor Protocol Methods

__get__(self, instance, owner) → Handles attribute retrieval

Called when the attribute is accessed on an instance.

class MyDescriptor:
    def __get__(self, instance, owner):
        print("Getting value")
        return instance._value  # Return stored value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
obj.value  # Calls __get__()

__set__(self, instance, value) → Handles attribute assignment

Called when an attribute is set. Often used for validation or transformations.

class MyDescriptor:
    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Negative value not allowed")
        print("Setting value")
        instance._value = value  # Store validated value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
obj.value = 10  # Calls __set__()

__delete__(self, instance) → Handles attribute deletion

Called when an attribute is deleted using del.

class MyDescriptor:
    def __delete__(self, instance):
        print("Deleting value")
        del instance._value  # Remove stored value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
del obj.value  # Calls __delete__()

Data vs Non-Data Descriptors

  • Data descriptors → Implement both __get__ and (__set__ or __delete__).

  • Non-data descriptors → Implement only __get__.

Data descriptors have higher precedence than instance attributes, while non-data descriptors do not.

class NonData:
    def __get__(self, instance, owner):
        return "Non-data descriptor"

class Data:
    def __get__(self, instance, owner):
        return "Data descriptor"

    def __set__(self, instance, value):
        pass

class Example:
    nd = NonData()
    d = Data()

e = Example()
e.nd = "Instance attribute"
print(e.nd)  # 'Instance attribute' because NonData is non-data descriptor
print(e.d)   # 'Data descriptor' because Data takes precedence

How Python’s Built-ins Use Descriptors

property() example

The property() function creates a descriptor internally, which manages attribute access.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    def get_radius(self):
        return self._radius

    def set_radius(self, value):
        if value < 0:
            raise ValueError("Negative radius not allowed")
        self._radius = value

    radius = property(get_radius, set_radius)

c = Circle(5)
print(c.radius)  # Calls get_radius()
c.radius = 10    # Calls set_radius()

Real-World Use Cases for Descriptors

1. Validation → Ensure values meet requirements

class PositiveNumber:
    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Only positive values allowed")
        instance._value = value

class Account:
    balance = PositiveNumber()

a = Account()
a.balance = -100  # Raises ValueError

2. Caching → Store expensive computations

class CachedProperty:
    def __get__(self, instance, owner):
        if "_cached_value" not in instance.__dict__:
            instance._cached_value = instance.expensive_computation()
        return instance._cached_value

class MyClass:
    cache = CachedProperty()

    def expensive_computation(self):
        print("Running expensive computation...")
        return 42

obj = MyClass()
print(obj.cache)  # Runs computation once
print(obj.cache)  # Returns cached value

3. Logging and Debugging → Track attribute changes

class LoggingDescriptor:
    def __set__(self, instance, value):
        print(f"Setting {instance.__class__.__name__}.value to {value}")
        instance._value = value

class MyClass:
    value = LoggingDescriptor()

obj = MyClass()
obj.value = 100  # Logs assignment

Best Practices

  • Use descriptors when multiple classes need shared attribute logic.

  • Keep descriptor classes modular and reusable.

  • Use property() for simpler cases.

  • Avoid excessive complexity—only use descriptors when truly necessary.

Limitations

  • Increased complexity → Can make code harder to debug.

  • Performance impact → Descriptors add a level of indirection in attribute lookup.

  • Not always necessaryproperty() is often sufficient.

Conclusion

Descriptors are one of Python’s most powerful mechanisms for controlling attribute behavior. They are used internally in Python for built-in features like property(), and they provide fine-grained control over attribute access.

By mastering descriptors, you gain a deeper understanding of Python internals—preparing you to build robust frameworks, APIs, and high-performance applications.

More from this blog

Naveen P.N's Tech Blog

94 posts