Attributes with Superpowers: A Practical Deep Dive into Python Descriptors
Descriptors are the mechanism behind properties, methods, and ORM fields. Learn how __get__, __set__, and __set_name__ work, the crucial data vs. non-data distinction, and how to build reusable validators and cached properties.
You have used descriptors already, probably without knowing it. Every time you write @property, call a method, or access a field on a Django model, a descriptor is doing the work behind the scenes. Descriptors are the low-level protocol that lets an attribute run code when it is read, written, or deleted. Once you understand them, a lot of Python's "magic" stops being magic and becomes a small, predictable set of rules.
This post walks from the familiar property down to raw descriptors, explains the all-important distinction between data and non-data descriptors, and finishes with two genuinely useful patterns you can drop into real code: reusable validators and a cached property.
The problem descriptors solve
Say you want an attribute that validates its input. The classic tool is property:
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = -300 # ValueError: Below absolute zero
This works, but it does not scale. If you have ten numeric fields that all need the same "must be positive" check, you end up copy-pasting ten nearly identical getter/setter pairs. A property is tied to one class and one attribute. Descriptors let you write the validation logic once and reuse it across attributes and classes. In fact, property itself is just a descriptor.
The descriptor protocol
A descriptor is any object that defines at least one of these methods:
__get__(self, obj, objtype=None)— runs when the attribute is read.__set__(self, obj, value)— runs when the attribute is assigned.__delete__(self, obj)— runs ondel.
The catch that trips up beginners: descriptors only fire when they live on the class, not on an instance. Here is the smallest possible one:
class Descriptor:
def __set_name__(self, owner, name):
# Called automatically when the owning class is created.
self.name = name
def __get__(self, obj, objtype=None):
if obj is None: # accessed on the class, e.g. Point.x
return self
return obj.__dict__[self.name]
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Point:
x = Descriptor()
y = Descriptor()
p = Point()
p.x = 3
p.y = 4
print(p.x, p.y) # 3 4
Two things deserve attention. First, __set_name__ (added in Python 3.6) is called automatically at class-creation time and hands the descriptor the attribute name it was assigned to. Before 3.6 you had to pass the name in by hand. Second, the descriptor stores each instance's value in obj.__dict__, keyed by the attribute name, rather than on itself. This is critical: a single descriptor object is shared by every instance of the class, so storing state on self would leak data between instances.
A reusable validator
Now the payoff. Here is one descriptor that enforces "must be a positive number" and can be reused on as many fields and classes as you like:
class PositiveNumber:
def __set_name__(self, owner, name):
self.private_name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.private_name[1:]} must be a number")
if value <= 0:
raise ValueError(f"{self.private_name[1:]} must be positive")
setattr(obj, self.private_name, value)
class Order:
price = PositiveNumber()
quantity = PositiveNumber()
def __init__(self, price, quantity):
self.price = price # validation runs here
self.quantity = quantity
o = Order(9.99, 3)
print(o.price) # 9.99
Order(-1, 3) # ValueError: price must be positive
Order("free", 3) # TypeError: price must be a number
Assigning to self.price in __init__ triggers __set__, so objects can never enter an invalid state — even during construction. The actual value lives under a private name like _price, keeping it out of the way of the descriptor. Add a third validated field to Order, or reuse PositiveNumber in an entirely different class, and you write zero new validation code.
Data vs. non-data descriptors
This is the single most important concept, and the one most tutorials skip. The behavior of a descriptor depends on which methods it defines:
- A data descriptor defines
__set__or__delete__. It takes priority over the instance's__dict__. - A non-data descriptor defines only
__get__. The instance's__dict__takes priority over it.
That precedence rule explains a lot of real behavior. Watch what happens when an entry in the instance dict shadows a non-data descriptor:
class NonData:
def __get__(self, obj, objtype=None):
return "from descriptor"
class A:
val = NonData()
a = A()
print(a.val) # from descriptor
a.__dict__["val"] = "from instance"
print(a.val) # from instance <-- instance dict wins
A data descriptor is not shadowable this way, because it sits ahead of the instance dict in the lookup order. This is exactly why you cannot accidentally overwrite a property by assigning to the same name — property defines __set__, so it is a data descriptor and always wins. The full attribute lookup order for obj.attr is: data descriptors on the type, then the instance __dict__, then non-data descriptors and plain class attributes.
Building a cached property
The non-data rule is not just trivia — it enables a neat optimization. Because a non-data descriptor yields to the instance dict, a descriptor can compute an expensive value once, write it into obj.__dict__ under its own name, and then get out of the way on every future access:
class lazy_property:
def __set_name__(self, owner, name):
self.name = name
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self.func(obj) # compute once
obj.__dict__[self.name] = value # shadow the descriptor
return value
class DataSet:
def __init__(self, rows):
self.rows = rows
@lazy_property
def total(self):
print("computing...")
return sum(self.rows)
d = DataSet(range(1000))
print(d.total) # computing... \n 499500
print(d.total) # 499500 (no "computing..." — served from __dict__)
The first access runs the function and stores the result; every later access finds total already in the instance dict and never touches the descriptor again. The standard library ships exactly this as functools.cached_property, so reach for that in production — but now you know precisely how it works. Note that if you had defined __set__, it would become a data descriptor, the instance dict would no longer take precedence, and the caching trick would silently break.
Methods are descriptors too
One more piece of the puzzle: ordinary functions are non-data descriptors. That is how a plain def inside a class becomes a bound method that automatically receives self. When you access instance.method, the function's __get__ returns a bound method with instance already wired in:
class Greeter:
def hello(self):
return "hi"
g = Greeter()
print(g.hello) # bound method
print(Greeter.hello) # plain function
print(Greeter.hello.__get__(g)) # manually binding — same as g.hello
Common pitfalls
A few traps to remember. Do not store per-instance values on the descriptor itself (self.value = ...) — the descriptor is shared, so every instance would clobber the others. Always route storage through obj.__dict__ or getattr/setattr with a private name. Second, remember descriptors must live on the class; assigning a descriptor object to an instance attribute does nothing special. Third, handle the obj is None case in __get__ so that class-level access (like Order.price) returns the descriptor instead of crashing. Finally, if you need caching, deliberately keep the descriptor non-data — adding __set__ quietly changes the precedence rules.
Wrap-up and next steps
Descriptors are the unifying idea behind properties, bound methods, classmethod, staticmethod, functools.cached_property, and every ORM field you have ever used. The mental model is small: a descriptor is a class attribute with __get__/__set__/__delete__; defining __set__ or __delete__ makes it a data descriptor that outranks the instance dict, while a __get__-only descriptor yields to it. From there, reusable validators and lazy caching fall out naturally.
To go further, read the official Descriptor HowTo Guide in the Python docs, then study how property, functools.cached_property, and a small ORM field class are implemented. Try refactoring a class riddled with repetitive @property validators into a single reusable descriptor — it is one of those changes that makes code shorter and clearer at the same time.