Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Python

Why Python Descriptors Matter (And How to Use Them)

Descriptors power Python features like @property and @classmethod. This guide explains the descriptor protocol with practical examples, from lazy properties to validation, helping you understand Python's attribute access under the hood.

July 2026 8 min read 2 views 0 hearts

Why Most Python Developers Don’t Know Descriptors (And Why You Should)

If you’ve been working with Python for a while, you’ve probably used @property, @classmethod, or __slots__ without a second thought. What you might not realize is that each of these features is actually powered by the same underlying mechanism: the descriptor protocol.

Descriptors are one of those Python features that sound intimidating in theory but are surprisingly simple once you see them in action. At PythonSkillset, we believe that understanding descriptors is what separates someone who just writes Python from someone who truly understands how Python works under the hood.

So What Exactly Is a Descriptor?

A descriptor is an object attribute that has one of three special methods: __get__, __set__, or __delete__. When you access an attribute on a class or instance, Python checks whether that attribute implements any of these methods. If it does, Python uses those methods instead of the standard attribute lookup process.

Here’s the simplest possible descriptor:

class SimpleDescriptor:
    def __get__(self, obj, objtype=None):
        print(f"Getting from {obj} of type {objtype}")
        return 42

class MyClass:
    attr = SimpleDescriptor()

obj = MyClass()
print(obj.attr)  # Getting from <__main__.MyClass object at 0x...> of type <class '__main__.MyClass'>
                  # 42

When you access obj.attr, Python sees that SimpleDescriptor has a __get__ method, so it calls that method instead of returning the descriptor object itself.

The Three Kinds of Descriptors

  1. Data descriptors – implement both __get__ and __set__ (or __delete__)
  2. Non-data descriptors – implement only __get__
  3. Regular attributes – implement none of the above

This distinction matters because data descriptors take priority over instance attributes. Non-data descriptors, however, can be overridden by assigning to the instance.

A Practical Example: Lazy Properties

One of the most common use cases for descriptors is creating lazy evaluation properties. Instead of computing an expensive value every time, you compute it once and cache it.

class LazyProperty:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        obj.__dict__[self.name] = value  # Cache in instance dict
        return value

class Report:
    def __init__(self, data):
        self.data = data

    @LazyProperty
    def expensive_calculation(self):
        print("Running expensive calculation...")
        return sum(self.data) ** 2

report = Report([1, 2, 3, 4, 5])
print(report.expensive_calculation)  # Runs calculation
print(report.expensive_calculation)  # Returns cached value, no calculation

Since LazyProperty is a non-data descriptor (only has __get__), once we store the value in obj.__dict__, subsequent lookups find the instance attribute first and never call __get__ again.

The @property Descriptor

When you use @property, you’re actually using a descriptor. Here’s what it looks like under the hood:

property(fget=None, fset=None, fdel=None, doc=None)

The property class implements the full descriptor protocol. When you access a property, it calls fget. When you assign to it, it calls fset. This is why properties give you that clean dot-notation interface while still allowing custom logic.

Real-World Application: Validation

At PythonSkillset, we’ve used descriptors to build a simple validation system for our configuration objects:

class ValidatedAttribute:
    def __init__(self, validator, default=None):
        self.validator = validator
        self.default = default
        self.data = {}

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.data.get(id(obj), self.default)

    def __set__(self, obj, value):
        if not self.validator(value):
            raise ValueError(f"Invalid value: {value}")
        self.data[id(obj)] = value

class Config:
    port = ValidatedAttribute(
        validator=lambda x: 1024 <= x <= 65535,
        default=8080
    )
    host = ValidatedAttribute(
        validator=lambda x: isinstance(x, str) and len(x) > 0,
        default='localhost'
    )

config = Config()
print(config.port)  # 8080
config.port = 8000  # Works fine
config.port = 50    # Raises ValueError

Notice how we used id(obj) as the key to separate instance data. This avoids the common bug of sharing state across all instances of the class.

When Should You Use Descriptors?

Descriptors shine when you need: - Reusable attribute behavior (validation, logging, caching) - Framework code where you want to intercept attribute access - Complex get/set logic that should be encapsulated

For simple cases, just use @property. But when you find yourself writing the same property pattern over and over again, a descriptor is the right tool.

Common Pitfalls to Watch Out For

  1. Descriptor state sharing: Always remember that descriptors are class attributes. If you store instance-specific data in the descriptor itself, all instances will share it.

  2. Performance: Each attribute access through a descriptor has overhead. For performance-critical paths, consider whether a descriptor is really necessary.

  3. Readability: Descriptors can make code harder to understand if overused. Use them only when they improve the design, not just because they’re cool.

The Bottom Line

Descriptors are Python’s way of letting you customize attribute access in a reusable, elegant manner. They power many of Python’s most beloved features, and understanding them gives you deeper insight into how the language works.

Next time you use @property, remember: you’re not just decorating a method. You’re tapping into a protocol that has been part of Python’s DNA since version 2.2. And now that you know how it works, you can build your own protocol-level tools too.

Happy coding, and see you at PythonSkillset!

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.