DEV Community

qing
qing

Posted on

Unlock Python's 5 Magic Descriptors

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:

  1. Check if x is a data descriptor on the class → call __set__ or __get__
  2. Check if x exists in obj.__dict__ → return that value
  3. Check if x is a non-data descriptor → call __get__
  4. Raise AttributeError if 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
Enter fullscreen mode Exit fullscreen mode

Here’s what’s happening:

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

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

Now you write:

class Order:
    total = NonNegativeAuto()
    discount = NonNegativeAuto()
Enter fullscreen mode Exit fullscreen mode

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

  1. Copy the NonNegative or NonNegativeAuto class into your project.
  2. Add it to any class with numeric attributes.
  3. 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 Tracked descriptor 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 CheatsheetFollow 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)