Home Tutorials OOP

OOP

Descriptors in Python: The Protocol Behind Attribute Access

Pyford Notes July 6, 2026 9 min read
Key points
  • A descriptor is any object whose class defines __get__, __set__, or __delete__, placed as a class attribute of another class.
  • Data descriptors (defining __set__ or __delete__) take priority over instance __dict__ entries; non-data descriptors (only __get__) do not.
  • property, classmethod, staticmethod, and even plain functions turning into bound methods are all implemented as descriptors under the hood.
  • A descriptor instance is shared across every instance of the owning class, so it must store per-instance state keyed by the instance, not on itself.

The descriptor protocol: three methods

A descriptor is an object whose class implements any of __get__(self, obj, owner), __set__(self, obj, value), or __delete__(self, obj). What makes it special is where it gets used: when a descriptor instance is assigned as a class attribute, accessing that attribute on an instance of the class routes through the descriptor's methods instead of doing a plain dictionary lookup.

class LoggedAttribute:
    def __get__(self, obj, owner):
        print(f"reading from {obj}")
        return obj.__dict__.get("_value")

    def __set__(self, obj, value):
        print(f"writing {value!r} to {obj}")
        obj.__dict__["_value"] = value

class Widget:
    name = LoggedAttribute()   # a class attribute that is a descriptor

w = Widget()
w.name = "gadget"    # prints "writing 'gadget' to "
print(w.name)        # prints "reading from " then "gadget"

Nothing here is magic syntax — w.name = "gadget" is ordinary attribute assignment. Python's attribute lookup machinery checks whether type(w).name is a descriptor before falling back to a plain __dict__ read or write, and routes through __get__/__set__ when it is.

Data descriptors vs non-data descriptors

The protocol splits into two tiers with different precedence. A data descriptor defines __set__ or __delete__ (with or without __get__); it always wins over an entry of the same name in the instance's own __dict__. A non-data descriptor defines only __get__; an instance __dict__ entry with the same name takes priority over it instead.

This distinction explains a specific, otherwise-confusing behaviour: you can shadow a plain function (a non-data descriptor, since it only implements __get__ for the bound-method machinery) by assigning to instance.__dict__[name] directly, but you cannot shadow a property (a data descriptor, since it implements __set__ even if only to raise AttributeError) that way. The lookup order, in full, is: data descriptors on the class, then the instance __dict__, then non-data descriptors and plain class attributes.

Writing a reusable validating descriptor

The practical reason to write a custom descriptor instead of a one-off property is reuse: a descriptor class can be instantiated multiple times across different attributes, or across different classes entirely, with the validation logic written once:

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.private_name = "_" + name

    def __get__(self, obj, owner):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.private_name[1:]} must be positive, got {value}")
        setattr(obj, self.private_name, value)

class Order:
    quantity = PositiveNumber()
    unit_price = PositiveNumber()

    def __init__(self, quantity, unit_price):
        self.quantity = quantity      # runs through PositiveNumber.__set__
        self.unit_price = unit_price

order = Order(3, 9.99)
# order.quantity = -1   # raises ValueError: quantity must be positive, got -1

__set_name__, added in Python 3.6, is called automatically when the owning class is created, and tells the descriptor what attribute name it was assigned to — without it, a descriptor written once and reused for several attributes on the same class would have no way to know which underlying storage key belongs to which attribute.

What property and functions already are

property is itself a data descriptor implemented in C: its __get__ calls your getter function, its __set__ calls your setter (or raises AttributeError if you didn't define one). Plain functions are non-data descriptors too — a function's __get__ is what turns instance.method into a bound method with self already filled in, rather than a plain function object sitting in the class __dict__. classmethod and staticmethod are descriptors that customise what gets bound: classmethod's __get__ binds the class instead of the instance, and staticmethod's binds nothing at all. Seeing all of these as the same underlying mechanism explains why they behave consistently: attribute lookup never has to special-case any of them.

The one gotcha: shared state across instances

A descriptor instance lives on the class, not on each object that uses it — there is exactly one PositiveNumber() object shared by every Order instance in the example above. Storing the value directly on self inside the descriptor (rather than on the owning object via obj.__dict__ or setattr(obj, ...)) is the single most common mistake: it makes every instance of the class share the same value, since there's only one descriptor object backing all of them. The fix, shown above, is always to key storage off the passed-in obj, never off the descriptor's own self. Python's own descriptor HOWTO works through the full attribute-resolution algorithm in pure Python if you want to see the exact order of operations.