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.
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:
__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:
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>>> 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 -1Three 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 onePositiveclass knows whether it's guardingpriceorquantity.obj is Nonemeans the attribute was read from the class (Product.price) rather than an instance. Returning the descriptor itself is the convention, and it keeps tools likehelp()working.- The real values live on the instance, under
_priceand_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__(Positiveandpropertyare data descriptors). - A non-data descriptor defines only
__get__(plain functions are non-data descriptors).
The difference matters because the instance dictionary sits between them. You can prove it:
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 descriptorThat 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:
>>> 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)
TrueBecause 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):
>>> 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:
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>>> 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:
>>> 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 recomputescached_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:
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 bStore 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:
>>> 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_propertyand__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.