Python - Python Descriptors: Controlling Attribute Access

Python descriptors are an advanced but important feature that allows developers to control how attributes are accessed, assigned, and deleted in a class. They provide a mechanism for customizing the behavior of attributes instead of treating them as simple values stored directly on an object.

Descriptors are widely used internally by Python and are the foundation of several important features, including property, methods, staticmethod, and classmethod. Understanding descriptors helps developers understand how Python handles attribute access behind the scenes.

1. What Is a Descriptor?

A descriptor is an object that defines one or more of the following special methods:

  • __get__()

  • __set__()

  • __delete__()

These methods allow the descriptor to control what happens when an attribute is read, modified, or deleted.

A simple descriptor can look like this:

class Descriptor:
    def __get__(self, instance, owner):
        print("Getting the value")

    def __set__(self, instance, value):
        print("Setting the value")

    def __delete__(self, instance):
        print("Deleting the value")

The descriptor can then be assigned as a class attribute:

class Student:
    name = Descriptor()

When an object of Student is created, operations involving name can be controlled by the descriptor.

student = Student()

student.name
student.name = "Rahul"
del student.name

Instead of Python directly accessing or modifying name, the descriptor methods can determine what happens.


2. Why Are Descriptors Useful?

Normally, when you write:

class Student:
    def __init__(self, name):
        self.name = name

Python stores name directly in the object's instance namespace.

For example:

student = Student("Rahul")

The object might internally contain:

{
    "name": "Rahul"
}

However, sometimes an application needs more control.

For example, you might want to:

  • Validate an attribute before storing it.

  • Convert an assigned value into another format.

  • Prevent certain values from being assigned.

  • Calculate a value dynamically.

  • Log every access to an attribute.

  • Store attributes in a different location.

  • Make an attribute read-only.

  • Apply common validation rules to many classes.

Descriptors provide a reusable mechanism for implementing these behaviors.


3. The __get__() Method

The __get__() method controls what happens when a descriptor-managed attribute is accessed.

Its basic structure is:

def __get__(self, instance, owner):
    ...

There are three important concepts here:

self

This refers to the descriptor object itself.

instance

This refers to the object through which the attribute is being accessed.

owner

This refers to the class that owns the descriptor.

Consider:

class Descriptor:
    def __get__(self, instance, owner):
        print("Attribute accessed")
        return "Python"

class Student:
    subject = Descriptor()

Now:

student = Student()
print(student.subject)

The descriptor's __get__() method is called.

The output is:

Attribute accessed
Python

The descriptor has therefore replaced normal attribute retrieval with customized behavior.


4. Accessing the Descriptor Through the Class

A descriptor can also be accessed through the class itself.

For example:

print(Student.subject)

In this situation, there is no particular Student object involved.

Therefore, instance is generally None.

A descriptor can handle this case:

class Descriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self

        return "Python"

Now:

student = Student()

print(student.subject)
print(Student.subject)

The first access operates on the object, while the second accesses the descriptor through the class.

This distinction is important when designing reusable descriptors.


5. The __set__() Method

The __set__() method controls what happens when a value is assigned to a descriptor-managed attribute.

Its structure is:

def __set__(self, instance, value):
    ...

For example:

class PositiveNumber:
    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Value cannot be negative")

        instance._value = value

class Product:
    price = PositiveNumber()

Now:

product = Product()

product.price = 100

The descriptor receives the assignment.

But:

product.price = -50

raises:

ValueError: Value cannot be negative

This is one of the most useful applications of descriptors: centralizing validation logic.


6. The __delete__() Method

The __delete__() method controls what happens when an attribute is deleted.

For example:

class Descriptor:
    def __delete__(self, instance):
        print("Attribute deleted")

Suppose:

class Student:
    name = Descriptor()

Then:

student = Student()
del student.name

causes the descriptor's __delete__() method to execute.

This can be useful when deletion needs additional processing or restrictions.


7. A Complete Descriptor Example

Consider a student application where marks must always be between 0 and 100.

Without a descriptor, validation might be repeated:

class Student:
    def __init__(self, marks):
        if marks < 0 or marks > 100:
            raise ValueError("Marks must be between 0 and 100")

        self.marks = marks

For multiple attributes, this validation can become repetitive.

A descriptor can make the validation reusable:

class ValidMarks:
    def __get__(self, instance, owner):
        return instance._marks

    def __set__(self, instance, value):
        if value < 0 or value > 100:
            raise ValueError("Marks must be between 0 and 100")

        instance._marks = value


class Student:
    marks = ValidMarks()

Now:

student = Student()

student.marks = 85

print(student.marks)

Output:

85

But:

student.marks = 120

produces:

ValueError: Marks must be between 0 and 100

The descriptor handles the validation automatically.


8. Why Is _marks Used?

You may notice that the descriptor manages:

marks

but stores the actual value as:

_marks

This is important.

Consider:

def __set__(self, instance, value):
    instance.marks = value

This would cause the descriptor to call __set__() again, resulting in recursive behavior.

Instead, the descriptor stores the value under another attribute:

instance._marks = value

Then __get__() retrieves it:

return instance._marks

This creates a separation between the public attribute and the internal storage attribute.


9. Data Descriptors and Non-Data Descriptors

Descriptors are commonly divided into two categories:

  1. Data descriptors

  2. Non-data descriptors

This distinction is important because Python gives them different priority during attribute lookup.

Data Descriptors

A descriptor that defines either:

__set__()

or:

__delete__()

is considered a data descriptor.

For example:

class Descriptor:
    def __get__(self, instance, owner):
        pass

    def __set__(self, instance, value):
        pass

Because it defines __set__(), it is a data descriptor.

Data descriptors generally take priority over attributes stored in an object's __dict__.


Non-Data Descriptors

A descriptor that only defines:

__get__()

is a non-data descriptor.

For example:

class Descriptor:
    def __get__(self, instance, owner):
        return "value"

Non-data descriptors have different lookup behavior because an instance attribute can take precedence over them.

This distinction becomes particularly important when understanding Python's method lookup mechanism.


10. Attribute Lookup and Descriptors

When you write:

student.name

Python needs to determine where name comes from.

The lookup process involves several possible locations, including:

  • Data descriptors on the class.

  • The instance's attribute dictionary.

  • Non-data descriptors and other class attributes.

  • Parent classes.

Descriptors participate directly in this process.

This is one reason descriptors are considered an advanced Python concept. They do not simply provide another way to write methods; they interact with Python's attribute lookup mechanism.


11. Descriptors and property

One of the most familiar examples of descriptor behavior is Python's property.

Consider:

class Student:
    def __init__(self, name):
        self._name = name

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

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

Here:

student = Student("Rahul")

print(student.name)

calls the property getter.

When:

student.name = "Arun"

is executed, the property setter is called.

A property object implements descriptor behavior internally.

Therefore, understanding descriptors helps explain how @property works beneath the surface.


12. Descriptors and Methods

Python methods also rely on descriptor behavior.

Consider:

class Student:
    def display(self):
        print("Student information")

When you write:

student = Student()
student.display()

Python obtains the display function from the class and creates a bound method associated with student.

This behavior is connected to the descriptor protocol.

Functions defined inside classes implement descriptor behavior that allows Python to automatically bind the instance to the method.

This is why:

student.display()

effectively provides the instance to:

display(self)

without the programmer explicitly passing student.


13. Descriptors for Data Validation

One of the most practical uses of descriptors is validation.

For example, an application may require usernames to be strings:

class StringValue:
    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError("Value must be a string")

        instance._value = value

    def __get__(self, instance, owner):
        return instance._value

Then:

class User:
    username = StringValue()

Now:

user = User()
user.username = "Rahul"

works correctly.

But:

user.username = 100

raises an error.

The same descriptor can potentially be reused across different classes.


14. Reusable Descriptors

A major advantage of descriptors is that the validation logic can be separated from business logic.

For example:

class PositiveNumber:
    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError("Value must be positive")

        instance._value = value

    def __get__(self, instance, owner):
        return instance._value

It can be reused:

class Product:
    price = PositiveNumber()


class Employee:
    salary = PositiveNumber()

Both classes can use the same validation mechanism.

This avoids duplicating the same validation code.


15. A More Advanced Descriptor

A descriptor can be designed to remember the name of the attribute it manages.

For example:

class ValidatedAttribute:
    def __set_name__(self, owner, name):
        self.name = name
        self.private_name = "_" + name

    def __get__(self, instance, owner):
        if instance is None:
            return self

        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError("Value must be a string")

        setattr(instance, self.private_name, value)

The __set_name__() method is called automatically when the descriptor is assigned to a class attribute.

For example:

class User:
    name = ValidatedAttribute()

Python informs the descriptor that its attribute name is:

name

The descriptor can then automatically create an internal name:

_name

This makes descriptors significantly more reusable.


16. The __set_name__() Method

Although the core descriptor protocol consists of __get__(), __set__(), and __delete__(), modern Python also provides:

__set_name__()

Its purpose is to tell a descriptor the name it was assigned to.

Example:

class Descriptor:
    def __set_name__(self, owner, name):
        print("Descriptor assigned to:", name)

Then:

class Student:
    name = Descriptor()
    age = Descriptor()

During class creation, Python calls __set_name__() for each descriptor.

The output will be similar to:

Descriptor assigned to: name
Descriptor assigned to: age

This feature is extremely useful for creating generic descriptors.


17. Descriptors vs. Properties

Descriptors and properties are closely related, but they are not exactly the same.

A property is generally convenient when you need custom behavior for one particular attribute in one class.

For example:

class Student:
    @property
    def age(self):
        return self._age

A descriptor is more appropriate when the same attribute behavior needs to be reused across multiple classes or attributes.

For example:

class PositiveNumber:
    ...

can potentially be used for:

class Product:
    price = PositiveNumber()

and:

class Employee:
    salary = PositiveNumber()

Therefore:

Property: convenient attribute customization.

Descriptor: reusable and more general attribute-management mechanism.


18. Common Applications of Descriptors

Descriptors can be useful in many areas of Python development.

Attribute validation

Descriptors can verify that assigned values meet specific requirements.

Type enforcement

They can restrict an attribute to particular data types.

Computed attributes

A descriptor can calculate a value whenever an attribute is accessed.

Lazy loading

A descriptor can delay expensive calculations or data retrieval until the attribute is actually needed.

Access control

Descriptors can restrict whether an attribute can be read, modified, or deleted.

Logging

They can record when attributes are accessed or changed.

Framework development

Descriptors are useful for creating reusable frameworks and APIs where attributes need customized behavior.


19. Advantages of Descriptors

Descriptors provide several important benefits.

Code reuse

A single descriptor can be used by multiple classes.

Centralized validation

Validation logic can be kept in one location.

Better separation of responsibilities

Business logic can remain separate from attribute-management logic.

Flexible attribute behavior

Developers can control reading, writing, and deletion of attributes.

Framework-friendly design

Descriptors provide powerful building blocks for libraries and frameworks.


20. Limitations and Considerations

Descriptors are powerful, but they are not necessary for every Python program.

They can make code harder to understand if used unnecessarily.

For simple validation, a regular property may be easier to maintain.

For example, this:

@property
def age(self):
    return self._age

may be easier to understand than implementing a full descriptor.

Descriptors are most valuable when:

  • The behavior needs to be reused.

  • Many attributes require the same logic.

  • A framework needs customized attribute access.

  • You need fine-grained control over attribute lookup.

Developers should therefore use descriptors when their additional complexity provides a real benefit.


21. Key Difference Between Normal Attributes and Descriptors

A normal attribute generally stores data directly:

student.name = "Rahul"

The value is associated with the object.

A descriptor can intercept that operation:

student.name = "Rahul"

and decide what should happen.

It can:

  • Validate "Rahul".

  • Transform the value.

  • Store it under another name.

  • Reject the assignment.

  • Perform additional operations.

This makes descriptors a powerful mechanism for controlling attribute behavior.


22. Summary

Python descriptors provide a mechanism for customizing attribute access and management. A descriptor is an object that implements methods such as __get__(), __set__(), and __delete__().

The main methods are:

Method Purpose
__get__() Controls reading an attribute
__set__() Controls assigning a value
__delete__() Controls deleting an attribute
__set_name__() Allows a descriptor to know the attribute name assigned to it

Descriptors are particularly useful for validation, type checking, reusable attribute behavior, access control, computed values, and framework development.

They also explain several important Python features, including property and method binding. Although descriptors are an advanced concept, learning them provides a much deeper understanding of how Python's object model and attribute lookup system work.