Python Descriptors: The Magic Behind Properties
tags: python, oop, tutorial, advanced
tags: python, oop, tutorial, advanced
You’ve probably used @property to wrap attributes with getters and setters, but you might not realize that Python descriptors are the hidden engine making it all work. Every time you access an attribute with dot notation, Python isn’t just pulling a value from a dictionary—it’s potentially invoking custom logic defined by a descriptor. Understanding this mechanism unlocks a powerful way to enforce validation, compute values on demand, and write cleaner, more maintainable code.
Let’s peel back the curtain and see how descriptors turn ordinary attributes into intelligent, behavior-rich components.
What Is a Descriptor?
In Python, a descriptor is any object that implements at least one of the following methods:
__get__(self, instance, owner)__set__(self, instance, value)-
__delete__(self, instance)[2][6]
When a class defines any of these methods, its instances become descriptors. Assigning such an instance as a class attribute triggers custom behavior whenever that attribute is accessed, assigned, or deleted on an instance [2][3].
Descriptors act as intermediaries in attribute access, letting you inject logic like validation, type checking, or lazy computation without changing how you use the attribute [2][8].
Data vs. Non-Data Descriptors
Descriptors fall into two categories based on which methods they implement:
| Type | Methods Implemented | Behavior |
|---|---|---|
| Data descriptor |
__get__ + __set__/__delete__
|
Overrides instance attributes of the same name; always controls access [1][2] |
| Non-data descriptor | Only __get__
|
Can be shadowed by instance attributes; only readable [1][5] |
Data descriptors are readable and writable, while non-data descriptors are read-only [1]. This distinction is crucial because data descriptors take precedence over instance attributes in the attribute lookup order [4][9].
How Descriptors Work Under the Hood
When you access an attribute like obj.name, Python follows this lookup order:
- Check if
nameis a data descriptor in the class → call__set__or__get__. - Check if
nameexists in the instance’s__dict__. - Check if
nameis a non-data descriptor in the class → call__get__. - Raise
AttributeErrorif not found [4][9].
This means data descriptors always win over instance attributes, giving you full control over how values are stored and retrieved [2][4].
A Practical Example: Validation with a Descriptor
Let’s build a validated integer descriptor that ensures any assigned value is an integer and within a specific range. This is something you can use TODAY to enforce data integrity without cluttering your classes with validation logic.
class ValidatedInt:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
self.name = None # Will be set by __set_name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be an integer")
if value < self.min_val or value > self.max_val:
raise ValueError(f"{self.name} must be between {self.min_val} and {self.max_val}")
instance.__dict__[self.name] = value
class Person:
age = ValidatedInt(0, 120)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30)
print(p.age) # 30
p.age = 25 # OK
# p.age = 150 # ValueError
# p.age = "25" # TypeError
In this example, ValidatedInt is a data descriptor because it implements both __get__ and __set__ [1][2]. The __set_name__ method (added in Python 3.6) automatically captures the attribute name when the class is defined, making the descriptor reusable and self-documenting [4][9].
Now, every time you assign to age, Python calls __set__ to validate the value. No extra checks in __init__, no decorators—just clean, declarative validation.
Descriptors Are the Magic Behind @property
You might be surprised to learn that @property is just a built-in descriptor [2][7]. The property() function creates a descriptor class that manages getter, setter, and deleter methods behind the same dot notation you use for regular attributes.
class MyClass:
def __init__(self):
self._value = 0
@property
def value(self):
return self._value
@value.setter
def value(self, new_value):
if new_value < 0:
raise ValueError("value must be non-negative")
self._value = new_value
Behind the scenes, value is a property descriptor instance with __get__, __set__, and __delete__ methods [2]. By writing your own descriptors, you can create custom decorators that do more than @property—like type checking, lazy loading, or automatic caching.
When to Use Descriptors Today
Descriptors shine in these practical scenarios:
- Attribute validation: Enforce types, ranges, or formats without repeating code [2][8].
- Lazy loading: Compute values only when accessed, saving memory and startup time [8].
- Computed properties: Return dynamic values based on other attributes [8].
- Encapsulation: Hide private data while exposing controlled access [2].
- Reusable patterns: Share logic across multiple classes (e.g., logging, auditing) [8].
If you’ve ever written the same validation logic in multiple __init__ methods, a descriptor can replace it with a single, reusable class.
Common Pitfalls and Best Practices
-
Don’t store data in the descriptor instance: Use
instance.__dict__to store per-instance values. Storing in the descriptor itself leads to shared state across all instances [9]. -
Implement
__set_name__: It makes your descriptor self-aware and avoids hardcoding attribute names [4][9]. -
Choose data vs. non-data wisely: If you need write access, implement
__set__to make it a data descriptor [1][5]. - Avoid shadowing: Non-data descriptors can be overridden by instance attributes; data descriptors cannot [4][9].
Conclusion: Own the Magic
Descriptors are not just an advanced Python trick—they’re a fundamental tool for writing robust, expressive code. By mastering them, you gain the ability to customize attribute access in ways that @property alone can’t match. You can enforce validation, compute values dynamically, and encapsulate logic cleanly, all while keeping your code intuitive and Pythonic.
Start small: replace one repetitive validation block with a custom descriptor. Then, experiment with lazy loading or computed properties. The more you use descriptors, the more you’ll see how they simplify complex logic and make your classes more maintainable.
Your turn: Write a descriptor that enforces a specific type (e.g., str, float) and share it with your team. Tag your post on Dev.to and let’s build a library of reusable descriptors together!
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.
Top comments (0)