Python Descriptors: The Magic Behind Properties
You’ve probably used @property dozens of times to wrap attributes with getters and setters, but few realize that properties are just a thin wrapper over Python descriptors—the real magic that powers custom attribute behavior. Understanding descriptors unlocks a deeper level of control, letting you build reusable validation logic, type checks, and even domain-specific attribute rules without touching every class individually.
Let’s peel back the curtain and see how descriptors work under the hood, then build something you can drop into your projects today.
What Is a Descriptor?
In Python, a descriptor is any object that implements at least one of these methods:
__get__(self, instance, owner)__set__(self, instance, value)-
__delete__(self, instance)[1][5]
When a class defines any of these, its instances become descriptors. They act as intermediaries in attribute access, intercepting reads, writes, and deletions to inject custom logic[1].
Descriptors are the foundation behind:
@property@staticmethod@classmethod- Any custom attribute access you’ve ever written[7]
Data vs. Non-Data Descriptors
Descriptors split into two categories based on which methods they implement:
| Type | Methods Implemented | Behavior |
|---|---|---|
| Data descriptor |
__get__ + __set__/__delete__
|
Controls both read and write; overrides instance attributes with the same name[1][2] |
| Non-data descriptor | Only __get__
|
Read-only; instance attributes can override it[2][8] |
This distinction is critical: data descriptors win over instance attributes during attribute access, while non-data descriptors lose to them. That’s why @property (a data descriptor) can’t be bypassed by setting obj.name = "new" without triggering your setter.
How Descriptors Actually Work
When you access an attribute like obj.x, Python follows this order:
- Check if
xis a data descriptor on the class → call__set__or__get__ - Check if
xexists inobj.__dict__→ return that value - Check if
xis a non-data descriptor → call__get__ - Raise
AttributeErrorif nothing found[8]
This lookup protocol is where the “magic” happens. You’re not just storing values—you’re defining behavior for how those values are accessed.
Build a Reusable Validation Descriptor TODAY
Let’s create a practical descriptor that enforces non-negative numbers on any attribute. This is reusable across classes and avoids copy-pasting validation logic everywhere.
class NonNegative:
def __init__(self, name):
self.name = name
self.storage_key = f"__{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.storage_key)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name} must be non-negative")
instance.__dict__[self.storage_key] = value
class Product:
price = NonNegative("price")
quantity = NonNegative("quantity")
def __init__(self, price, quantity):
self.price = price
self.quantity = quantity
p = Product(10.5, 3)
print(p.price) # 10.5
p.price = 20 # OK
p.price = -5 # Raises ValueError
Here’s what’s happening:
-
NonNegativeis a data descriptor (has__get__and__set__)[1] - It stores values in
instance.__dict__under a hidden key (__price,__quantity) - The setter validates the value before storing it
- You reuse the same descriptor class across multiple attributes
No more scattered if value < 0 checks. Just declare price = NonNegative("price") and you’re done.
Descriptors vs. @property: When to Use Each
@property is simpler for per-attribute logic, but it’s not reusable:
class Product:
def __init__(self, price):
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("Price must be non-negative")
self._price = value
This works, but if you have 10 attributes needing the same validation, you repeat the setter 10 times. A descriptor like NonNegative solves that with one class, unlimited reuse.
Use @property when:
- Logic is unique to that attribute
- You want simplicity and readability
Use descriptors when:
- You need reusability across attributes or classes
- You want to enforce consistent rules (validation, typing, logging)
- You’re building libraries or frameworks with custom attribute behavior
Advanced: Custom Setter with set_name
Python 3.6+ adds __set_name__(self, owner, name), which automatically receives the attribute name when assigned on a class. This lets you drop the manual name argument:
class NonNegativeAuto:
def __set_name__(self, owner, name):
self.name = name
self.storage_key = f"__{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.storage_key)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name} must be non-negative")
instance.__dict__[self.storage_key] = value
Now you write:
class Order:
total = NonNegativeAuto()
discount = NonNegativeAuto()
Cleaner, more Pythonic, and ready for real-world use.
Why This Matters
Descriptors aren’t just academic—they’re the engine behind Python’s most powerful features. By mastering them, you gain:
- Reusable attribute logic without repetition
- Cleaner separation of validation and data storage
- Framework-level control over how attributes behave
Next time you’re tempted to write another @property setter, ask: Could a descriptor do this better?
Try It Yourself
- Copy the
NonNegativeorNonNegativeAutoclass into your project. - Add it to any class with numeric attributes.
- Break it intentionally (
obj.x = -1) and watch the error.
Then, experiment:
- Add type checking (
if not isinstance(value, int): ...) - Log every assignment (
print(f"Setting {self.name} to {value}")) - Build a
Trackeddescriptor that records all changes in a history list
Descriptors are your Swiss Army knife for attribute behavior. Once you’ve built one, you’ll never go back to scattered validation logic.
What’s the most creative descriptor you’ve built? Share your code in the comments or on GitHub—I’d love to see what you come up with.
If you found this helpful, consider buying me a coffee ☕ — it keeps these articles coming!
Also check out my AI tools collection: AI 次元世界 — free AI tools for developers.
📧 Get my FREE Python Cheatsheet → Follow me on Dev.to and drop a comment below — I'll DM you the cheatsheet directly!
🐍 50+ essential Python patterns, one-liners, and best practices for everyday development. Free for all readers.
If you found this useful, you might like Python Interview Prep Guide — a practical resource that takes things a step further. At $24.99 it's a solid investment for your toolkit.
喜欢这篇文章?关注获取更多Python自动化内容!
Top comments (0)