PythonAdvanced

Python Descriptors: The Protocol Behind Properties and Methods

@property, bound methods, cached_property and __slots__ all run on one small protocol. Learn how descriptors work, the exact attribute lookup order, and how to write your own.

A dotted attribute access, obj.price, routed through a descriptor's __get__ and __set__ methods
On this page

Have you ever wondered how @property turns a method into something that looks like a plain attribute? Or where self actually comes from when you call obj.method()? Or how Django model fields and SQLAlchemy columns know which object they belong to?

They all rely on the same small mechanism: the descriptor protocol. It's one of the few genuinely "magic" parts of Python, and once you understand it you can read the language's internals and write cleaner, reusable attribute logic of your own.

If decorators are still new to you, read Python Decorators Explained Step by Step first. We'll use them below.

What is a descriptor?#

A descriptor is any object whose class defines at least one of these methods:

Python
__get__(self, obj, objtype=None)   # called when the attribute is read
__set__(self, obj, value)          # called when it is assigned
__delete__(self, obj)              # called on `del obj.attr`

The catch: it only works when the descriptor is stored as a class attribute. When Python looks up obj.attr and finds a descriptor on the class, it calls the descriptor's method instead of returning the descriptor itself.

Your first descriptor: a validated attribute#

Say every Product needs a positive price and quantity. With properties you'd write the same getter and setter twice. With a descriptor you write the rule once and reuse it:

positive.pyPython
class Positive:
    """A number that must stay above zero."""

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

    def __get__(self, obj, objtype=None):
        if obj is None:          # accessed on the class: Product.price
            return self
        return getattr(obj, self.private_name)

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


class Product:
    price = Positive()
    quantity = Positive()

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price          # goes through Positive.__set__
        self.quantity = quantity
Python
>>> p = Product("Keyboard", 49.0, 3)
>>> p.price
49.0
>>> vars(p)
{'name': 'Keyboard', '_price': 49.0, '_quantity': 3}
>>> p.quantity = -1
ValueError: quantity must be positive, got -1

Three details are worth noticing:

  • __set_name__ (Python 3.6+) is called once, when the class is created, with the attribute name the descriptor was assigned to. That's how one Positive class knows whether it's guarding price or quantity.
  • obj is None means the attribute was read from the class (Product.price) rather than an instance. Returning the descriptor itself is the convention, and it keeps tools like help() working.
  • The real values live on the instance, under _price and _quantity. The descriptor only holds configuration.

The attribute lookup order#

To use descriptors confidently you need the exact rules object.__getattribute__ follows for obj.attr. Descriptors come in two kinds:

  • A data descriptor defines __set__ or __delete__ (Positive and property are data descriptors).
  • A non-data descriptor defines only __get__ (plain functions are non-data descriptors).
obj.attrchecks these in order and stops at the first match 1234 Data descriptor on the classInstance dictionaryNon-data descriptor or class attributeFallback method property, __slots__ members, Positive()obj.__dict__['attr']methods, classmethod, cached_property__getattr__(self, name) __get__(obj, type)the stored value__get__() or the valueits result, or AttributeError
Python checks a data descriptor on the class first, then the instance dictionary, then non-data descriptors and class attributes, and finally __getattr__

The difference matters because the instance dictionary sits between them. You can prove it:

lookup_order.pyPython
class DataDesc:
    def __get__(self, obj, objtype=None):
        return "data descriptor"

    def __set__(self, obj, value):
        raise AttributeError("read-only")


class NonDataDesc:
    def __get__(self, obj, objtype=None):
        return "non-data descriptor"


class Demo:
    a = DataDesc()
    b = NonDataDesc()


d = Demo()
d.__dict__["a"] = "instance dict"     # bypass __set__ and write directly
d.__dict__["b"] = "instance dict"

print(d.a)   # data descriptor   ← beats the instance dict
print(d.b)   # instance dict     ← beats the non-data descriptor

That one rule explains a surprising amount of Python's behaviour, as the next sections show.

Methods are descriptors#

Functions have a __get__ method. When you read a function through an instance, __get__ returns a bound method, a small object that remembers both the function and the instance. That's where self comes from:

Python
>>> class Greeter:
...     def hello(self):
...         return "hi"
...
>>> g = Greeter()
>>> f = Greeter.__dict__["hello"]    # the raw function, no descriptor magic
>>> f
<function Greeter.hello at 0x7fc890f70160>
>>> f.__get__(g, Greeter)            # exactly what g.hello does
<bound method Greeter.hello of <__main__.Greeter object at 0x7fc890f63760>>
>>> g.hello == f.__get__(g, Greeter)
True

Because functions are non-data descriptors, an instance attribute with the same name wins. That's why you can override a method on a single object (handy for mocks in tests):

Python
>>> g.hello = lambda: "patched"
>>> g.hello(), Greeter().hello()
('patched', 'hi')

classmethod and staticmethod are non-data descriptors too: their __get__ binds the class instead of the instance, or binds nothing at all.

Rebuilding @property#

property is a data descriptor written in C, but a working pure-Python version fits on one screen:

my_property.pyPython
class MyProperty:
    def __init__(self, fget, fset=None):
        self.fget = fget
        self.fset = fset

    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 attribute")
        self.fset(obj, value)

    def setter(self, fset):
        return type(self)(self.fget, fset)    # a new descriptor with both functions


class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @MyProperty
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9
Python
>>> t = Temperature(100)
>>> t.fahrenheit
212.0
>>> t.fahrenheit = 32
>>> t.celsius
0.0

@MyProperty is just fahrenheit = MyProperty(fahrenheit). Then @fahrenheit.setter builds a second descriptor holding both functions and rebinds the name to it. Because MyProperty always defines __set__, it's a data descriptor even without a setter, which is why a read-only property can't be shadowed by assigning to the instance.

cached_property: using the lookup order on purpose#

functools.cached_property is a clever use of the rule from earlier. It's a non-data descriptor: the first access runs your method and stores the result in the instance's __dict__ under the same name. From then on the instance dictionary wins the lookup, so __get__ is never called again:

Python
>>> from functools import cached_property
>>> class Report:
...     def __init__(self, rows):
...         self.rows = rows
...
...     @cached_property
...     def total(self):
...         print("computing…")
...         return sum(self.rows)
...
>>> r = Report([3, 4, 5])
>>> r.total
computing…
12
>>> r.total          # served straight from r.__dict__
12
>>> vars(r)
{'rows': [3, 4, 5], 'total': 12}
>>> del r.total      # clear the cache; the next access recomputes

cached_property needs an instance dict

Since the cache lives in obj.__dict__, cached_property doesn't work on classes that define __slots__ without including "__dict__".

The classic bug: storing state on the descriptor#

There's only one descriptor object per class attribute, shared by every instance. Store per-object data on self inside a descriptor and all instances end up sharing it:

Python
class Broken:
    def __get__(self, obj, objtype=None):
        return self.value

    def __set__(self, obj, value):
        self.value = value     # stored on the descriptor, which every instance shares


class Account:
    balance = Broken()


a, b = Account(), Account()
a.balance = 100
b.balance = 5
print(a.balance)   # 5  ← a's balance was overwritten by b

Store values on obj instead, as Positive does with setattr(obj, self.private_name, value).

Avoid infinite recursion

Inside __set__, never write setattr(obj, self.public_name, value). Assigning to the public name calls __set__ again, forever. Always store under a different name, or write to obj.__dict__ directly.

Where else you'll meet descriptors#

Once you know the pattern you'll spot it everywhere:

Feature Descriptor kind What __get__ does
Plain methods non-data returns a bound method
classmethod / staticmethod non-data binds the class / binds nothing
property data calls the getter function
functools.cached_property non-data computes once, caches in obj.__dict__
__slots__ entries data reads a fixed slot in the object's memory
Django fields, SQLAlchemy columns depends on the field load the value from the database, convert or track it

You can even see the __slots__ ones directly:

Python
>>> class Point:
...     __slots__ = ("x", "y")
...
>>> Point.__dict__["x"]
<member 'x' of 'Point' objects>

Recap#

  • A descriptor is a class attribute whose class defines __get__, __set__ or __delete__.
  • Data descriptors (with __set__ or __delete__) beat the instance dictionary; non-data descriptors lose to it.
  • Methods, property, classmethod, staticmethod, cached_property and __slots__ are all built on descriptors.
  • Use __set_name__ to learn the attribute's name, and store per-instance values on the instance, never on the descriptor.
  • Reach for a custom descriptor when the same attribute logic (validation, conversion, lazy loading) repeats across several attributes or classes.

The best way to make this stick: open a REPL, write a descriptor that prints in each method, and watch exactly when Python calls it.