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:
-
Data descriptors
-
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.