Descriptors are the least-known and most powerful mechanism behind Python's attribute access. @property is a descriptor. Django and SQLAlchemy model fields are descriptors. Once you understand the protocol, you understand what's actually happening every time you write obj.attribute.
1What Actually Happens on obj.attribute
When you write p.price, Python doesn't simply look up "price" in p.__dict__ and stop. The actual algorithm (implemented in object.__getattribute__) checks the *class* (and its base classes, via MRO) for an attribute named price first; if that class-level attribute is a data descriptor ā an object implementing both __get__ and __set__ (or __delete__) ā Python calls its __get__ method and returns that result *even if* the instance also has its own price entry in p.__dict__. Only if there's no matching data descriptor on the class does Python fall back to checking the instance's own __dict__ directly, and only after that does it check for a non-data descriptor (one with just __get__) on the class.
This is precisely how PositiveNumber, assigned as price = PositiveNumber() inside the Product class body, intercepts every p.price and p.price = value on every instance of Product: the descriptor lives once on the class, but __get__/__set__ receive instance as an argument, so it can read and write a per-instance value (stored under a different name, _price, to avoid infinite recursion) while still centralizing the validation logic in one place.
__set_name__, called automatically when the class body finishes executing, tells the descriptor what name it was assigned to ("price"), which is how PositiveNumber derives its private storage attribute name ("_price") without you needing to pass it explicitly.
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name)
def __set__(self, instance, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
setattr(instance, self.name, value)Checks class for a data descriptor first ā calls __get__(p, Product)
2Data Descriptors vs Non-Data Descriptors
The distinction between a data descriptor (has __set__ and/or __delete__, in addition to __get__) and a non-data descriptor (has only __get__) governs priority when an instance attribute of the same name also exists. A data descriptor always wins over an instance's own __dict__ entry ā this is deliberate and is exactly what makes @property-based validation impossible to bypass by simply assigning instance.__dict__['price'] = -5 through some other code path, since that route is never even checked when a data descriptor is present.
A non-data descriptor, by contrast, only applies when the instance itself has no same-named entry in its own __dict__ ā this is precisely how *methods* work. A plain function defined in a class body is a non-data descriptor (functions implement __get__, returning a bound method when accessed through an instance), which is why instance.some_attribute = some_value can shadow a method of the same name on that specific instance, while the same trick can never shadow a @property.
This two-tier priority (data descriptor > instance __dict__ > non-data descriptor > class __dict__ fallback) is the complete algorithm behind every obj.attribute lookup in Python, and understanding it explains behavior that otherwise looks like special-cased magic ā why properties can't be overridden per-instance, why methods can, and why self.__dict__ sometimes 'wins' and sometimes doesn't.
class Product:
price = PositiveNumber() # descriptor lives on the CLASS
def __init__(self, price: float):
self.price = price # triggers __set__
p = Product(19.99)
print(p.price) # triggers __get__ -> 19.99
p.price = -5 # triggers __set__ -> raises ValueError3@property Is Just a Built-In Descriptor
property is not special syntax handled by the interpreter ā it's an ordinary built-in class, implementing exactly the same __get__/__set__/__delete__ protocol as the hand-written PositiveNumber class. @property above a method definition is sugar for calling property(fget=that_method), and @price.setter above a second method named price returns a *new* property object combining the original getter with the new setter, reassigned back onto the name price in the class namespace.
This equivalence means everything covered about descriptor priority applies identically to @property: a property is a data descriptor (it has __set__, even a property with no explicit setter defines one that raises AttributeError on assignment), so it always takes precedence over anything you might try to put in instance.__dict__ under the same name ā which is exactly why p.price = -5 raises ValueError from your custom setter logic rather than silently succeeding.
The practical decision between @property and a hand-written descriptor class comes down to reuse: @property is ideal for a single attribute on a single class, while a custom descriptor class like PositiveNumber is worth writing once you need the *same* validation logic (positive numbers, non-empty strings, bounded ranges) reused across multiple attributes or multiple classes ā write the validation once as a descriptor, then assign instances of it to as many class attributes as needed.
class Product:
def __init__(self, price: float):
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value <= 0:
raise ValueError("price must be positive")
self._price = valueSugar for price = property(fget=..., fset=...)
4Step-by-Step Breakdown
obj.attribute isn't always 'just' a dictionary lookup. Descriptors let a class intercept and control attribute access entirely ā this is how @property really works under the hood.
A descriptor is any object implementing __get__ (and optionally __set__/__delete__), assigned as a CLASS attribute ā not an instance attribute.
Assign the descriptor as a class attribute, and every instance's attribute access is routed through __get__/__set__ automatically.
Checkpoint: Where must a descriptor object be assigned for the protocol to activate?
- āAs a class attribute (e.g. price = PositiveNumber() inside the class body)
- āAs an instance attribute, inside __init__
@property is literally a built-in descriptor factory ā this hand-rolled version behaves identically to using @property with a setter.
Checkpoint: Is @property built on top of the descriptor protocol, or is it a separate, unrelated mechanism?
- āproperty is itself a descriptor ā @property is literally a descriptor factory
- āIt is a completely separate mechanism from descriptors
Descriptors intercept individual attribute access ā magic methods generalize that same idea to nearly every operator and built-in behavior an object can have.
Validate with a Real Descriptor. Finish PositiveNumber.__set__(): a descriptor's __set__ runs automatically on every assignment.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Use @property for one-off validated attributes; write a reusable descriptor class for repeated validation logic
Copy-pasting the same @property getter/setter pair across five classes is exactly the duplication a small descriptor class (like PositiveNumber) eliminates by centralizing the logic once.
Always implement __set_name__ on custom descriptors instead of hardcoding the storage attribute name
Hardcoding a private name inside __init__ breaks the moment the same descriptor class is reused for a differently-named attribute; __set_name__ derives it automatically and correctly from wherever the descriptor is assigned.
Frequent Bugs
Storing the value directly as self.name = value inside a descriptor's __set__ using the PUBLIC attribute name, causing infinite recursion (setting price calls __set__, which sets price again, which calls __set__...).
Store the actual value under a different, private attribute name (e.g. an underscore-prefixed name derived via __set_name__) on the instance, never under the exact same public name the descriptor itself is exposed as.
Real-World Examples
A Reusable Validated-String Descriptor
Multiple model classes (User, Product, Order) each need a "non-empty string" field with identical validation logic, without copy-pasting the same @property getter/setter into every class.
class NonEmptyString:
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name)
def __set__(self, instance, value):
if not value or not value.strip():
raise ValueError(f"{self.name} cannot be empty")
setattr(instance, self.name, value)
class User:
username = NonEmptyString()
email = NonEmptyString()