Unveiling Python Descriptors Through Get, Set, and Delete Protocols
Wenhao Wang
Dev Intern · Leapcell

Introduction
In the world of Python, behind many seemingly simple operations like accessing an attribute or calling a method, lies a powerful and often underestimated mechanism: the descriptor protocol. Descriptors are the silent architects that allow Python to be so dynamic and flexible in how it handles object attributes. Have you ever wondered how @property
decorators work, or how un-bound and bound methods manage their self
argument? The answer often traces back to descriptors. Understanding this protocol is not just about dissecting Python's internals; it's about gaining the ability to write more expressive, robust, and Pythonic code. This article will unravel the mystery of Python descriptors by exploring their fundamental __get__
, __set__
, and __delete__
methods, showcasing their principles, implementation, and practical applications.
Understanding Descriptors
Before diving into the core protocols, let's establish what a descriptor is. In Python, an object that implements any of the __get__
, __set__
, or __delete__
methods is called a descriptor. These methods are special because when an object with these methods is assigned to a class attribute, Python delegates attribute access for instances of that class to the descriptor's methods. This delegation is the cornerstone of the descriptor protocol.
There are two main types of descriptors:
- Data Descriptors: Objects that define both
__get__
and__set__
. They are called "data" because they can both read and write data. - Non-Data Descriptors: Objects that only define
__get__
. They are primarily used for retrieval and cannot directly modify the attribute's value in the instance's dictionary.
The distinction between data and non-data descriptors is important because it affects the lookup order for attributes. Data descriptors take precedence over instance dictionaries, while non-data descriptors can be overridden by instance dictionaries.
Now, let's explore the three pillars of the descriptor protocol: __get__
, __set__
, and __delete__
.
The __get__
method
The __get__
method is invoked when an attribute is accessed. Its signature is typically __get__(self, instance, owner)
, where:
self
: The descriptor instance itself.instance
: The instance of the class on which the attribute was accessed. If the attribute is accessed directly from the class (e.g.,MyClass.attribute
),instance
will beNone
.owner
: The class that owns the descriptor (e.g.,MyClass
).
Let's illustrate with an example:
class MyDescriptor: def __init__(self, value=None): self._value = value def __get__(self, instance, owner): if instance is None: print(f"Accessing descriptor directly from class '{owner.__name__}'") return self print(f"Getting attribute from instance '{instance}' (value: {self._value})") return self._value def __set__(self, instance, value): print(f"Setting attribute for instance '{instance}' to '{value}'") self._value = value class MyClass: descriptor_attr = MyDescriptor(10) # Accessing from class print(MyClass.descriptor_attr) # Creating an instance and accessing obj = MyClass() print(obj.descriptor_attr) # Output: # Accessing descriptor directly from class 'MyClass' # <__main__.MyDescriptor object at 0x...> # When accessed from class, returns the descriptor itself # Getting attribute from instance '<__main__.MyClass object at 0x...>' (value: 10) # 10
When MyClass.descriptor_attr
is accessed, __get__
is called with instance
as None
, returning the descriptor object itself. When obj.descriptor_attr
is accessed, __get__
is called with obj
as instance
, allowing the descriptor to return a value specific to that instance or a common value as shown here.
The __set__
method
The __set__
method is called when an attribute is assigned a value. Its signature is __set__(self, instance, value)
, where:
self
: The descriptor instance.instance
: The instance of the class on which the attribute was set.value
: The value being assigned to the attribute.
Continuing our previous example:
# ... (MyDescriptor and MyClass definitions as above) ... obj = MyClass() print(f"Initial obj.descriptor_attr: {obj.descriptor_attr}") obj.descriptor_attr = 20 # Calls MyDescriptor.__set__ print(f"Updated obj.descriptor_attr: {obj.descriptor_attr}") # Output: # Initial obj.descriptor_attr: 10 # Setting attribute for instance '<__main__.MyClass object at 0x...>' to '20' # Updated obj.descriptor_attr: 20
Here, when obj.descriptor_attr = 20
is executed, the __set__
method of MyDescriptor
is invoked, allowing us to intercept and potentially modify or validate the assigned value. This is how properties implement read/write control.
The __delete__
method
The __delete__
method is invoked when an attribute is deleted using the del
keyword. Its signature is __delete__(self, instance)
, where:
self
: The descriptor instance.instance
: The instance of the class on which the attribute was deleted.
Let's extend our example to include __delete__
:
class MyDescriptor: def __init__(self, value=None): self._value = value def __get__(self, instance, owner): if instance is None: return self if not hasattr(instance, '_descriptor_storage'): return self._value # Fallback if not set on instance return instance._descriptor_storage def __set__(self, instance, value): if not hasattr(instance, '_descriptor_storage'): setattr(instance, '_descriptor_storage', value) else: instance._descriptor_storage = value print(f"Setting attribute for instance '{instance}' to '{value}'") def __delete__(self, instance): if hasattr(instance, '_descriptor_storage'): del instance._descriptor_storage print(f"Deleting attribute for instance '{instance}'") else: print(f"Cannot delete, attribute not found for instance '{instance}'") class MyClass: descriptor_attr = MyDescriptor(10) obj = MyClass() print(f"Initial obj.descriptor_attr: {obj.descriptor_attr}") # Uses __get__, falls back to default obj.descriptor_attr = 20 # Uses __set__ print(f"After setting obj.descriptor_attr: {obj.descriptor_attr}") # Uses __get__ from instance storage del obj.descriptor_attr # Calls __delete__ print(f"After deleting, trying to access obj.descriptor_attr: {obj.descriptor_attr}") # Uses __get__, falls back to default # Output: # Initial obj.descriptor_attr: 10 # Setting attribute for instance '<__main__.MyClass object at 0x...>' to '20' # After setting obj.descriptor_attr: 20 # Deleting attribute for instance '<__main__.MyClass object at 0x...>' # After deleting, trying to access obj.descriptor_attr: 10
In this enhanced example, we've made MyDescriptor
store values per instance in _descriptor_storage
. When del obj.descriptor_attr
is called, MyDescriptor.__delete__
is invoked, allowing us to perform cleanup, such as removing the attribute from the instance's internal storage. If we access obj.descriptor_attr
again, since the instance-specific storage is gone, it falls back to the default value provided during descriptor initialization.
Practical Applications
Descriptors are not just an academic curiosity; they are fundamental to how many Python features work.
-
@property
decorator: The@property
decorator is perhaps the most common use case. It transforms class methods into attributes, providing controlled access (getters, setters, deleters) to class members without altering the syntax.class Circle: def __init__(self, radius): self._radius = radius @property # This is a descriptor! def radius(self): print("Getting radius...") return self._radius @radius.setter # This is also a descriptor! def radius(self, value): if value < 0: raise ValueError("Radius cannot be negative") print(f"Setting radius to {value}...") self._radius = value c = Circle(5) print(c.radius) c.radius = 10 # c.radius = -1 # This would raise ValueError due to the setter
-
Bound and Unbound Methods: Every function defined inside a class becomes a descriptor. When you access
MyClass.method
, it's the descriptor itself (an unbound method in Python 2 or a regular function in Python 3). When you accessobj.method
, the descriptor's__get__
method is invoked, bindingobj
as the first argument (self
) to the function, resulting in a bound method. -
ORM Fields: Object-Relational Mappers (ORMs) like SQLAlchemy often use descriptors to represent database fields. This allows them to intercept attribute access, load data from the database on demand, and track changes for persistence.
-
Type Validation: Custom descriptors can enforce type checking or value constraints, ensuring data integrity within objects.
class Typed: def __init__(self, type_name, default=None): self.type_name = type_name self.default = default self.data = {} # Stores values per instance def __get__(self, instance, owner): if instance is None: return self return self.data.get(instance, self.default) def __set__(self, instance, value): if not isinstance(value, self.type_name): raise TypeError(f"Expected {self.type_name.__name__}, got {type(value).__name__}") self.data[instance] = value class Person: name = Typed(str, "Anonymous") age = Typed(int, 0) p = Person() print(p.name) p.name = "Alice" p.age = 30 # p.age = "thirty" # This would raise TypeError print(f"{p.name} is {p.age} years old.")
Conclusion
The Python descriptor protocol, embodied by the __get__
, __set__
, and __delete__
methods, provides a powerful and elegant way to customize attribute access. By understanding how these methods interact with class and instance attribute lookups, developers can unlock deeper control over object behavior, enabling the creation of robust and highly flexible systems. Descriptors are the unsung heroes that empower Python's sophisticated attribute management, making many of its most beloved features possible. They are a true testament to Python's extensible design.