Python - Python Descriptors: Building Managed Attributes
Python descriptors are one of the most powerful yet often overlooked features of the language. They provide a way to customize how attributes are accessed, modified, and deleted in an object. Descriptors are the foundation behind many built-in Python features such as properties, methods, static methods, class methods, and even parts of popular frameworks like Django and SQLAlchemy. By understanding descriptors, developers can write cleaner, reusable, and more controlled code for managing object attributes.
What Is a Descriptor?
A descriptor is any Python object that defines one or more of the following special methods:
-
__get__() -
__set__() -
__delete__()
These methods automatically control what happens when an attribute is accessed, assigned a value, or deleted.
Instead of directly storing or retrieving values, Python calls these methods whenever the attribute is used.
For example:
class Example:
def __get__(self, instance, owner):
print("Attribute accessed")
def __set__(self, instance, value):
print("Value assigned:", value)
def __delete__(self, instance):
print("Attribute deleted")
Python automatically invokes these methods without requiring explicit function calls.
Why Use Descriptors?
Normally, attributes are stored directly inside an object's dictionary.
Example:
class Student:
pass
s = Student()
s.name = "John"
print(s.name)
Output:
John
In this case, Python simply stores the value.
However, what if you want to:
-
Validate data before storing it
-
Prevent invalid values
-
Log attribute access
-
Automatically calculate values
-
Encrypt stored information
-
Reuse validation across many classes
Descriptors make these tasks simple and reusable.
The Descriptor Protocol
Python recognizes a descriptor when a class defines any of these methods.
get()
Called whenever an attribute is read.
Syntax:
__get__(self, instance, owner)
Parameters:
-
self – descriptor object
-
instance – object whose attribute is being accessed
-
owner – class of the object
Example:
class Display:
def __get__(self, instance, owner):
return "Hello from Descriptor"
class Student:
message = Display()
s = Student()
print(s.message)
Output:
Hello from Descriptor
Python automatically calls __get__().
set()
Called whenever an attribute is assigned a value.
Example:
class Number:
def __set__(self, instance, value):
print("Assigned:", value)
class Demo:
value = Number()
d = Demo()
d.value = 100
Output:
Assigned: 100
Instead of directly storing the value, Python executes __set__().
delete()
Called whenever an attribute is deleted.
Example:
class DemoDescriptor:
def __delete__(self, instance):
print("Deleted")
class Test:
item = DemoDescriptor()
t = Test()
del t.item
Output:
Deleted
Types of Descriptors
Python has two kinds of descriptors.
Data Descriptor
Implements:
-
__get__() -
__set__()
or
-
__delete__()
These descriptors have higher priority than instance variables.
Example:
class DataDescriptor:
def __get__(self, instance, owner):
return "Descriptor Value"
def __set__(self, instance, value):
print("Cannot overwrite")
class Demo:
x = DataDescriptor()
d = Demo()
print(d.x)
Output:
Descriptor Value
Non-Data Descriptor
Implements only:
-
__get__()
Instance variables can override these descriptors.
Example:
class ReadOnly:
def __get__(self, instance, owner):
return "Read Only"
class Demo:
x = ReadOnly()
d = Demo()
print(d.x)
d.x = "Python"
print(d.x)
Output:
Read Only
Python
Since no __set__() exists, Python allows the instance variable to replace it.
Creating a Validation Descriptor
One practical use of descriptors is validating input.
Example:
class PositiveNumber:
def __set_name__(self, owner, name):
self.private_name = "_" + name
def __get__(self, instance, owner):
return getattr(instance, self.private_name)
def __set__(self, instance, value):
if value <= 0:
raise ValueError("Must be positive")
setattr(instance, self.private_name, value)
class Product:
price = PositiveNumber()
p = Product()
p.price = 500
print(p.price)
Output:
500
If the user enters:
p.price = -50
Output:
ValueError: Must be positive
The validation logic is written only once and can be reused across multiple classes.
Understanding set_name()
Python automatically calls this method when the class is created.
Example:
def __set_name__(self, owner, name):
self.private_name = "_" + name
If the attribute name is:
price
Python stores it internally as:
_price
This allows descriptors to work with multiple attributes without hardcoding names.
Reusing the Same Descriptor
Example:
class Positive:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, instance, owner):
return getattr(instance, self.name)
def __set__(self, instance, value):
if value <= 0:
raise ValueError("Positive values only")
setattr(instance, self.name, value)
class Product:
price = Positive()
quantity = Positive()
p = Product()
p.price = 100
p.quantity = 50
print(p.price)
print(p.quantity)
Output:
100
50
A single descriptor manages multiple attributes, reducing code duplication.
Descriptors vs Properties
Many developers use the @property decorator for attribute management.
Example:
class Student:
def __init__(self):
self._age = 0
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("Invalid age")
self._age = value
This works well for managing a single attribute within one class.
Descriptors, however, are more reusable because they can be shared across many classes and attributes without rewriting the same validation logic.
Practical Applications of Descriptors
Descriptors are commonly used in:
-
Data validation
-
Read-only attributes
-
Lazy loading of data
-
Computed attributes
-
Logging attribute access
-
Automatic type checking
-
Object-relational mapping (ORM) frameworks
-
Configuration management
-
Unit conversion systems
-
Caching expensive calculations
Advantages of Descriptors
-
Centralize attribute management in one place.
-
Reduce repetitive validation code.
-
Improve code reusability across multiple classes.
-
Enable automatic validation without modifying class methods.
-
Provide fine-grained control over attribute access.
-
Support advanced object-oriented programming techniques.
-
Form the basis of several built-in Python features.
Limitations of Descriptors
-
More complex than standard properties for beginners.
-
Can make code harder to understand if overused.
-
Debugging descriptor behavior requires knowledge of Python's attribute lookup mechanism.
-
Simple applications may not need the additional flexibility descriptors provide.
Best Practices
-
Use descriptors when the same attribute behavior needs to be reused across multiple classes.
-
Prefer
@propertyfor simple, class-specific attribute management. -
Keep descriptor logic focused on a single responsibility, such as validation or logging.
-
Use
__set_name__()to avoid hardcoding attribute names. -
Clearly document descriptor behavior so other developers understand how attributes are managed.
-
Test descriptors thoroughly to ensure they correctly handle valid and invalid values.
Conclusion
Descriptors are a core feature of Python's object model that provide complete control over how attributes are accessed, modified, and deleted. Although they are more advanced than properties, they offer exceptional flexibility and code reuse for tasks such as validation, logging, lazy loading, and computed attributes. Mastering descriptors helps developers understand how many of Python's built-in features work internally and enables the creation of robust, maintainable, and reusable object-oriented applications.