A descriptor is just an object that defines __get__, __set__, or __delete__ methods and is accessed via a class attribute.
Here’s a descriptor in action, managing a simple attribute:
class MyDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
print(f"Getting {self.name} for {instance} (owner: {owner})")
return instance.__dict__[self.name]
def __set__(self, instance, value):
print(f"Setting {self.name} for {instance} to {value}")
instance.__dict__[self.name] = value
class MyClass:
my_attr = MyDescriptor("my_attr")
obj = MyClass()
obj.my_attr = 100
print(obj.my_attr)
When you run this, you’ll see:
Setting my_attr for <__main__.MyClass object at 0x...> to 100
Getting my_attr for <__main__.MyClass object at 0x...> (owner: <class '__main__.MyClass'>)
100
The __get__ and __set__ methods of MyDescriptor are being called instead of direct attribute access on obj.
The core problem descriptors solve is how to customize attribute access. Normally, when you do obj.attr, Python looks up attr in obj.__dict__. If it’s not there, it looks in the class’s __dict__. Descriptors intercept this lookup. When Python finds a descriptor object as a class attribute (like MyClass.my_attr), it calls the descriptor’s __get__, __set__, or __delete__ method instead of directly accessing obj.__dict__ or the class dictionary.
__get__(self, instance, owner): This is called when an attribute is read.
self: The descriptor instance itself.instance: The object the attribute is being accessed on (e.g.,objinobj.my_attr). If the attribute is accessed on the class itself (e.g.,MyClass.my_attr),instancewill beNone.owner: The class that owns the descriptor (e.g.,MyClassinobj.my_attr).
__set__(self, instance, value): This is called when an attribute is written to.
self: The descriptor instance.instance: The object being assigned to.value: The value being assigned.
__delete__(self, instance): This is called when an attribute is deleted.
self: The descriptor instance.instance: The object from which the attribute is being deleted.
The instance.__dict__[self.name] = value line in the __set__ method is crucial. It stores the actual data for the attribute on the instance itself, not on the descriptor. This allows each instance to have its own value for the attribute managed by the descriptor. Similarly, __get__ retrieves the value from the instance’s __dict__.
The whole point is to enable logic between the attribute access and the actual data retrieval/storage. This is how properties, methods, and classmethods are implemented in Python. A property is essentially a descriptor that defaults to calling __get__, __set__, and __delete__ on some underlying data.
Consider the difference between obj.my_attr and MyClass.my_attr. When you access obj.my_attr, Python finds MyDescriptor in MyClass.__dict__. Since obj is an instance, __get__ is called with instance=obj and owner=MyClass. When you access MyClass.my_attr, __get__ is called with instance=None and owner=MyClass. This difference is how methods (which are descriptors) know whether they’re being called on the class or an instance.
The most surprising thing is that methods are descriptors. When you define def my_method(self): ... in a class, Python automatically wraps that function in a descriptor object (specifically, a method descriptor). When you access instance.my_method, the descriptor’s __get__ is invoked, and it returns a "bound method" object that has self already attached. Accessing MyClass.my_method returns the unbound function object.
The next concept you’ll run into is how to implement __delete__ correctly, and the subtle differences in attribute lookup precedence when descriptors, __slots__, and __getattr__ are involved.