DEV Community

qing
qing

Posted on

Python Descriptors: The Magic Behind Properties

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:

  1. Check if name is a data descriptor in the class → call __set__ or __get__.
  2. Check if name exists in the instance’s __dict__.
  3. Check if name is a non-data descriptor in the class → call __get__.
  4. Raise AttributeError if 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)