pyguides

__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:

  • self is the descriptor instance sitting on the class.
  • obj is the instance through which you accessed the attribute. It is None when accessed directly on the class.
  • objtype (often called owner) 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:

OrderSource
1Data descriptors in the class’s __mro__
2Instance __dict__
3Non-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

MethodSignatureTrigger
__get____get__(self, obj, objtype=None) -> AnyReading the attribute
__set____set__(self, obj, value) -> NoneAssigning to the attribute
__delete____delete__(self, obj) -> NoneDeleting 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