Python Descriptors: The Secret Behind @property and ORM Fields
When you use @property in Python, something interesting happens behind the scenes. The attribute doesn't just sit there waiting to be read — it intercepts access and runs custom code. How does that work?
The answer is descriptors. Think of them as "smart attributes" — attributes that can detect when they're being read, written to, or deleted, and run custom logic in response. They're the mechanism behind @property, @classmethod, @staticmethod, and even regular method calls.
Once you understand descriptors, you'll see them everywhere in Python. They're one of the most elegant patterns in the language, and they unlock powerful abilities like type validation, lazy loading, and computed properties.
What Makes Something a Descriptor?
A descriptor is any object that defines at least one of these three methods:
__get__(self, obj, objtype=None) — called when the attribute is read__set__(self, obj, value) — called when the attribute is written to__delete__(self, obj) — called when the attribute is deletedThis trio is called the descriptor protocol.
Every time you read or write u.name, Python doesn't just store the value directly. Instead, it calls the descriptor's __get__ or __set__ method. The descriptor acts as a gatekeeper for that attribute.
What Is the Difference Between Data and Non-Data Descriptors?
Descriptors come in two flavors:
__set__ and/or __delete__. They always win over instance attributes.__get__. Instance attributes can override them.This distinction matters a lot. Regular methods are non-data descriptors (they only have __get__), which is why you can override a method on an instance. Properties are data descriptors, which is why they always intercept access.
How Does @property Actually Work Under the Hood?
The built-in property class is just a descriptor. When you write @property, Python creates a descriptor object that stores your getter, setter, and deleter functions. Here's a simplified version:
The key insight: @MyProperty replaces the method with a descriptor instance. When you access c.radius, Python sees the descriptor and calls __get__, which in turn calls your original getter function.
What Are Some Practical Uses for Descriptors?
Descriptors shine when you need the same validation or transformation logic on multiple attributes. Instead of writing a @property for each one, you write one descriptor class and reuse it.
Notice __set_name__ — this is a Python 3.6+ feature that automatically tells the descriptor what attribute name it was assigned to. Before this existed, you had to pass the name manually (like TypeChecked('name', str)).
How Does __set_name__ Help Descriptors?
Before Python 3.6, descriptors had an annoying problem: they didn't know their own name. You had to write redundant code like name = TypeChecked('name', str) — repeating the attribute name.
__set_name__(self, owner, name) is called automatically when the class is created. It receives the owning class and the attribute name, so the descriptor can configure itself:
class Validated:
def __init__(self, name, validator):
self.name = name # Must pass manually
self.validator = validator
class Form:
email = Validated('email', is_email) # Redundant!class Validated:
def __init__(self, validator):
self.validator = validator
def __set_name__(self, owner, name):
self.name = name # Automatic!
class Form:
email = Validated(is_email) # Clean!Practice Exercises
Create a descriptor class called Positive that only allows positive numbers to be set. If someone tries to set a non-positive value, raise a ValueError with the message '{name} must be positive'.
Use it in a Rectangle class with width and height attributes. Create a Rectangle, set width=5 and height=3, then print both values.
What does the following code print?
class Desc:
def __get__(self, obj, objtype=None):
return 'descriptor'
class MyClass:
x = Desc()
obj = MyClass()
obj.__dict__['x'] = 'instance'
print(obj.x)Think carefully: is Desc a data descriptor or a non-data descriptor?
Create a descriptor TypeChecked that:
1. Accepts an expected_type in __init__
2. Uses __set_name__ to learn its attribute name automatically
3. Raises TypeError with '{name} must be {type_name}' on invalid assignment
Use it in a Config class with host (str) and port (int). Set host='localhost' and port=8080, then print them.
This descriptor has a common bug — all instances share the same value! Fix it so each instance has its own independent value.
Expected output:
Alice
BobCreate a non-data descriptor called CachedProperty that works as a decorator. It should:
1. Compute the value by calling the decorated function on first access
2. Store the result in the instance's __dict__ so it's only computed once
3. Use __set_name__ to learn the attribute name
Use it in a Stats class:
class Stats:
def __init__(self, numbers):
self.numbers = numbers
@CachedProperty
def total(self):
print('computing')
return sum(self.numbers)Create Stats([10, 20, 30]), access total twice, and print the result both times.
Create a descriptor InRange that accepts min_val and max_val parameters and ensures the value stays within that range. Raise ValueError with '{name} must be between {min} and {max}' for out-of-range values.
Use it in a GameCharacter class:
health = InRange(0, 100)level = InRange(1, 99)Create a character, set health=75 and level=10, then print both.