Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Tutorial

Python Descriptors: Building Property-Like Behavior from Scratch

Learn how Python descriptors work under the hood, build your own property-like behavior, and understand the secret sauce behind @property.

July 2026 12 min read 2 views 0 hearts

Have you ever wondered how @property actually works under the hood in Python? It's not magic—it's descriptors. Understanding descriptors is like learning the secret ingredient that makes Python's attribute system so flexible and powerful.

When I first started tinkering with Python, I thought @property was just some built-in decorator magic that I'd never fully understand. But then I discovered descriptors, and everything clicked. Let me show you how they work and how you can build your own.

What Exactly is a Descriptor?

A descriptor is any Python object that implements at least one of these methods: __get__, __set__, or __delete__. When you access an attribute on an object, Python checks if that attribute has a descriptor protocol—if it does, the descriptor's methods take over.

Think of it like this: normally, when you write obj.attribute, Python just looks up attribute in obj.__dict__. But if attribute is a descriptor, Python instead calls attribute.__get__(obj, type(obj)) and returns whatever that method gives you.

Building Your First Descriptor

Let's start with something practical. At Pythonskillset, we often need validated attributes—like ensuring an email address has an @ symbol or a username is long enough. Here's a simple validator descriptor:

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

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

    def __set__(self, obj, value):
        self.validator(value)
        self.data[id(obj)] = value

Now let's use it:

def validate_email(email):
    if '@' not in email:
        raise ValueError("Invalid email address")

class User:
    email = ValidatedAttribute(validate_email)

    def __init__(self, name, email):
        self.name = name
        self.email = email  # Validation happens here

# This works fine
user1 = User("Alice", "alice@pythonskillset.com")

# This raises ValueError
user2 = User("Bob", "bob_at_example.com")  # Error!

Notice something? Each instance gets its own storage (we used id(obj) as a key). That's because descriptors live on the class, not on instances. Without this technique, all instances would share the same value—which is almost never what you want.

The Magic Behind @property

Now that you've seen a basic descriptor, let's recreate @property from scratch. It's surprisingly simple:

class MyProperty:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc or fget.__doc__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

And here's how you'd use it:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @MyProperty
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

c = Circle(5)
print(c.radius)  # 5
c.radius = 10    # Works fine
c.radius = -3    # Raises ValueError

Descriptors in the Real World

You've actually been using descriptors without realizing it. Remember how staticmethod and classmethod work? They're both descriptors. When you call a static method, the descriptor returns the raw function. When you call a class method, it binds the class as the first argument.

Here's a real-world example from Pythonskillset's analytics system. We needed to track how many times certain methods were called:

class CallTracker:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        def wrapper(*args, **kwargs):
            self.count += 1
            print(f"{self.func.__name__} called {self.count} times")
            return self.func(obj, *args, **kwargs)
        return wrapper

class DataProcessor:
    @CallTracker
    def process(self, data):
        return [d * 2 for d in data]

dp = DataProcessor()
dp.process([1, 2, 3])  # Prints: process called 1 times
dp.process([4, 5, 6])  # Prints: process called 2 times

When Should You Use Descriptors?

Descriptors aren't something you'll use every day, but they're invaluable when you need:

  1. Reusable attribute logic - Like validation that you want across multiple classes
  2. Property-like behavior with shared code - When @property would mean repeating yourself
  3. Managing cross-cutting concerns - Like logging, caching, or access control on attributes

A good rule of thumb: if you find yourself writing the same getter/setter pattern across different classes, it's time to consider a descriptor.

Common Pitfalls to Avoid

  1. Forgetting instance-specific storage - Always use a dictionary keyed by id(obj) or store data in the instance's __dict__ instead of on the descriptor itself.

  2. Not handling obj is None - When you access the descriptor from the class (like MyClass.my_descriptor), obj will be None. Python expects you to return self in that case.

  3. Overcomplicating things - If a simple @property works, use it. Descriptors are for when you need reusable, generic attribute behavior.

Wrapping Up

Descriptors are one of those Python features that seem intimidating at first but become incredibly elegant once you understand them. They're the foundation for properties, classmethods, staticmethods, and many other Python internals.

Next time you use @property, remember that you're standing on the shoulders of the descriptor protocol. And now you have the power to create your own attribute behavior that's just as clean and reusable.

What's your next project at Pythonskillset going to be? Maybe a descriptor that auto-caches expensive computations, or one that logs every attribute change? The possibilities are endless once you understand this fundamental pattern.

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.