__get__ / __set__ / __delete__
__get__(self, obj, objtype=None) -> Any Python’s descriptor protocol lets custom objects define what happens when someone reads, writes, or deletes an attribute on a class. This is the same mechanism behind property(), @classmethod, @staticmethod, and bound methods. If you’ve ever wondered how @property intercepts your attribute access, or why obj.method gives you a bound method, the answer lives in these three dunder methods.
The Protocol
A descriptor is any object that implements __get__, __set__, or __delete__. Those three methods form the descriptor protocol. You implement them on a class, then put instances of that class as attributes on other classes.
class Descriptor:
def __get__(self, obj, objtype=None):
# Called on: obj.attr (read)
...
def __set__(self, obj, value):
# Called on: obj.attr = value (write)
...
def __delete__(self, obj):
# Called on: del obj.attr (delete)
...
Defining any one of these makes an object a descriptor. Defining both __get__ and at least one of __set__/__delete__ makes it a data descriptor — and data descriptors have special precedence in Python’s attribute lookup chain.
__get__(self, obj, objtype=None) -> Any
Called every time you read the attribute. The parameters carry specific meaning:
selfis the descriptor instance sitting on the class.objis the instance through which you accessed the attribute. It isNonewhen accessed directly on the class.objtype(often calledowner) is the class the descriptor is defined on.
class AlwaysThree:
def __get__(self, obj, objtype=None):
return 3
class Point:
x = AlwaysThree()
p = Point()
print(p.x) # 3
print(Point.x) # 3 — but obj is None here
When you access p.x, Python finds AlwaysThree on Point, then calls AlwaysThree.__get__(desc, p, Point). When you access Point.x directly, obj is None because no instance is involved. The return value is up to you — descriptors can return anything.
__set__(self, obj, value) -> None
Called on every assignment to the attribute. Unlike __get__, this always receives an actual instance as obj.
class RangeChecked:
def __init__(self, min_v, max_v):
self.min_v = min_v
self.max_v = max_v
def __set__(self, obj, value):
if not (self.min_v <= value <= self.max_v):
raise ValueError(f"{value} outside range [{self.min_v}, {self.max_v}]")
obj.__dict__["_health"] = value
def __get__(self, obj, objtype=None):
return obj.__dict__["_health"]
class Player:
health = RangeChecked(0, 100)
player = Player()
player.health = 50 # OK
player.health = 200 # raises ValueError
Because RangeChecked defines __set__, it is a data descriptor. Python will always route assignments through __set__, even if something already exists in the instance’s __dict__.
__delete__(self, obj) -> None
Called when del targets the attribute.
class WriteOnce:
def __set__(self, obj, value):
if hasattr(obj, "_value"):
raise RuntimeError("Already set")
obj._value = value
def __get__(self, obj, objtype=None):
return getattr(obj, "_value", None)
def __delete__(self, obj):
raise RuntimeError("Cannot delete this attribute")
The __delete__ method receives the instance. If you need to clean up associated state, you access it through obj.__dict__ directly.
Data Descriptors vs Non-Data Descriptors
The distinction matters. A data descriptor defines __get__ and at least one of __set__ or __delete__. A non-data descriptor defines only __get__.
This affects the attribute lookup order:
| Order | Source |
|---|---|
| 1 | Data descriptors in the class’s __mro__ |
| 2 | Instance __dict__ |
| 3 | Non-data descriptors in the class’s __mro__ |
| 4 | __getattr__ hook on the class (if defined) |
Because functions implement __get__ only, they are non-data descriptors. This is why you can shadow a method by putting a function directly in an instance’s __dict__:
class MyClass:
def method(self):
return "class method"
obj = MyClass()
obj.method = lambda self: "instance override"
print(obj.method) # lambda — from instance __dict__, bypassing the non-data descriptor
Data descriptors always win over the instance dict. That is why property() always intercepts assignment, even if something already lives in instance.__dict__:
class A:
@property
def val(self):
return self._val
a = A()
a.__dict__["val"] = 999 # Stashed directly in instance dict
print(a.val) # Still goes through property __get__ — property is a data descriptor
# Result: KeyError on _val
How property() Uses This Protocol
property() is implemented as a class that implements all three descriptor methods. It stores the getter, setter, and deleter functions you pass in, then calls them from __get__, __set__, and __delete__.
# Simplified implementation
class property:
def __init__(self, fget, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("read-only property")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("cannot delete this property")
self.fdel(obj)
Because it defines __set__, property is a data descriptor. Every @property you write is a descriptor instance on your class.
Bound Methods: Functions as Non-Data Descriptors
When a function is assigned to a class attribute, it becomes a non-data descriptor. Calling function.__get__(instance, Class) returns a bound method — the function with instance pre-bound as the first argument.
class MyClass:
def method(self):
return self
obj = MyClass()
print(obj.method) # <bound method MyClass.method of <MyClass object at ...>>
print(obj.method()) # <MyClass object at ...>>
# Equivalent:
print(MyClass.method.__get__(obj, MyClass))
This is entirely the descriptor protocol at work. Functions implement __get__, which is why methods on instances become bound automatically.
__set_name__(self, owner, name)
Python calls descriptor.__set_name__(owner, name) automatically when the class is created. This is the only built-in way a descriptor can discover what name it was assigned on the class.
class Field:
def __set_name__(self, owner, name):
print(f"{owner.__name__}.{name} assigned")
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__[self.name]
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Model:
x = Field()
y = Field()
# Output when class Model is created:
# Model.x assigned
# Model.y assigned
This hook solves a common problem: without it, descriptors have no way to know their own attribute name.
__get__ vs __getattr__
These are often confused but work differently. __getattr__ is a fallback hook on a class that only runs when normal attribute lookup has already failed. __get__ intercepts attribute access for a specific named attribute on every access.
class Lazy:
def __get__(self, obj, objtype=None):
print("descriptor __get__ called")
return 42
class Container:
x = Lazy()
def __getattr__(self, name):
print(f"__getattr__ called for {name}")
return 0
c = Container()
print(c.x) # descriptor __get__ called → 42
print(c.y) # __getattr__ called for y → 0
__get__ fires every time for the attribute it handles. __getattr__ only fires when nothing else found the attribute.
Common Use Cases
Validation
class Typed:
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be {self.expected_type.__name__}, got {type(value).__name__}")
obj.__dict__[self.name] = value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__[self.name]
class Point:
x = Typed(float)
y = Typed(float)
Point().x = 1.5 # OK
Point().x = "two" # raises TypeError
Lazy evaluation
class lazy:
def __init__(self, func):
self.func = func
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self.func(obj)
obj.__dict__[self.name] = value # Cache in instance dict
return value
On first access, the descriptor computes the value and stashes it in the instance dict. Because the descriptor has no __set__, subsequent reads find the value directly in __dict__ without re-computing.
Signature Reference
| Method | Signature | Trigger |
|---|---|---|
__get__ | __get__(self, obj, objtype=None) -> Any | Reading the attribute |
__set__ | __set__(self, obj, value) -> None | Assigning to the attribute |
__delete__ | __delete__(self, obj) -> None | Deleting the attribute |
All three are optional. Implement only what you need.
See Also
__call__— classes can define__call__to make instances callable; the descriptor__get__is what produces bound methods__new__—__new__creates and returns a new instance before__init__runs; descriptors attach to the class after construction__eq__— equality checks are separate dunder methods; the descriptor protocol intercepts attribute access for all attribute-based operations