Day 4 · ~18m

Descriptors: What @property Really Does Under the Hood

Learn how Python descriptors work: the __get__, __set__, __delete__ protocol that powers @property and lets you build reusable validated attribute logic.

student (thinking)

I've been using @property for months. After yesterday's lesson on __len__ and __getitem__, I'm starting to suspect it isn't actually syntax sugar. It's doing something.

teacher (neutral)

Good instinct. Let me show you what you've been thinking it does, and then what it actually does.

You probably picture @property as a fancy way to write a getter — some Python shortcut that turns a method call into attribute access. You type order.price and Python secretly calls get_price(). That's the mental model most people carry. It's not wrong, but it's missing the machinery.

The truth is that @property is not syntax sugar. It is an object. A specific kind of object that Python calls a descriptor. And descriptors are what happens when you realise that attribute access itself runs a protocol.

student (curious)

Attribute access runs a protocol? Like __len__ for len()?

teacher (encouraging)

Exactly like that. When Python evaluates order.price, it doesn't just fetch a value from a dictionary. It checks whether the object stored at price on the class defines any of three special methods: __get__, __set__, or __delete__. If it does, Python calls those instead of doing the plain dictionary lookup. That object — the one with those methods — is a descriptor.

@property is just Python's built-in descriptor. A pre-installed lock mechanism, ready to use. When you write this:

class Order:
    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError(f"price must be positive, got {value}")
        self._price = value

Python is doing this under the hood: it creates a property object and stores it at Order.price. That object has a __get__ that calls your getter function, and a __set__ that calls your setter function. The door handle looks the same from outside. The lock mechanism behind the panel is entirely different.

student (confused)

Wait. Order.price is an object? Not a value?

teacher (serious)

Yes. This is the part that breaks most people's mental model. order.price (on an instance) calls the descriptor's __get__ and returns the value. Order.price (on the class) returns the descriptor object itself — the property instance. You can see this directly:

order = Order()
order.price = 99.99

print(order.price)        # 99.99 — __get__ was called
print(type(Order.price))  # <class 'property'> — the descriptor object itself
print(Order.price.fget)   # <function Order.price at 0x...> — your getter function

The property object holds your getter and setter as plain function references. When Python sees order.price, it finds the property descriptor on the class, calls property.__get__(order, Order), which calls your function with order as self. The handle turns, the lock fires, the door opens. The audience sees: dot notation. The mechanism is: a three-method protocol.

student (surprised)

So @property has been running __get__ and __set__ this entire time and I never knew. Every @price.setter I've written was wiring up a descriptor.

teacher (amused)

Every single one. Welcome to the rigging room. The performance looked smooth because the mechanism was hidden. Now you can see the counterweights.

student (focused)

Okay. So if @property is just a built-in descriptor, I can write my own? A descriptor that does something property can't?

teacher (excited)

That's the whole point. A custom descriptor is building your own lock from scratch. You define a class with __get__, __set__, and optionally __delete__. Then you assign an instance of that class to a class attribute — and Python will invoke your methods whenever that attribute is accessed or assigned.

Here is the full descriptor protocol:

class MyDescriptor:
    def __get__(self, obj, objtype=None):
        # obj is the instance (or None if accessed on the class)
        # objtype is the class
        ...

    def __set__(self, obj, value):
        # called when you write: obj.attr = value
        ...

    def __delete__(self, obj):
        # called when you write: del obj.attr
        ...

You store data for each instance on obj.__dict__ directly, using a key — typically the attribute's name. That's why there's a fourth method worth knowing: __set_name__. Python calls it automatically when the descriptor is assigned to a class attribute, passing the name 'price' or 'status' so your descriptor knows what to call itself.

class MyDescriptor:
    def __set_name__(self, owner, name):
        self.name = name  # store 'price' or 'status'

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # class-level access returns the descriptor itself
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value
student (thinking)

__set_name__ — Python calls that automatically? So when I write price = MyDescriptor() inside a class body, Python immediately calls MyDescriptor.__set_name__(Order, 'price')?

teacher (neutral)

Exactly. The class body runs, MyDescriptor() is assigned to Order.price, and Python calls __set_name__ on it with owner=Order and name='price'. By the time any instance is created, your descriptor already knows its attribute name. You never have to pass 'price' as a string argument.

That's what makes descriptors reusable. A @property has to be rewritten for every attribute. A custom descriptor gets defined once and placed on any attribute that needs the same behaviour. Here's a real example: validated attributes on an Order class.

class ValidatedAttribute:
    def __set_name__(self, owner, name):
        self.name = name
        self.private = f'_{name}'  # store actual value at _price, _status

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

    def __set__(self, obj, value):
        # subclasses override this to add validation
        obj.__dict__[self.private] = value


class PositiveFloat(ValidatedAttribute):
    def __set__(self, obj, value):
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(f"{self.name} must be a positive number, got {value!r}")
        obj.__dict__[self.private] = float(value)


class ValidStatus(ValidatedAttribute):
    VALID = {'pending', 'paid', 'shipped', 'cancelled'}

    def __set__(self, obj, value):
        if value not in self.VALID:
            raise ValueError(f"{self.name} must be one of {sorted(self.VALID)}, got {value!r}")
        obj.__dict__[self.private] = value


class Order:
    price = PositiveFloat()
    status = ValidStatus()

    def __init__(self, price, status='pending'):
        self.price = price    # calls PositiveFloat.__set__
        self.status = status  # calls ValidStatus.__set__
order = Order(price=99.99)
print(order.price)   # 99.99

order.price = -10    # raises ValueError: price must be a positive number, got -10
order.status = 'maybe'  # raises ValueError: status must be one of [...]
student (excited)

This is the right abstraction. With @property, I'd write a getter-setter pair for every single attribute. With a descriptor, I define the rule once and reuse it. Price validation, status validation, date validation — all one class each.

teacher (proud)

And the Order class stays clean. All the validation logic lives in the descriptors. New validated attribute? One line on the class. The mechanism is no longer tangled into every property.

student (curious)

What does if obj is None: return self in __get__ actually guard against?

teacher (focused)

That guards class-level access. When Python evaluates Order.price — no instance, just the class — Python calls descriptor.__get__(None, Order). If you don't check for None, your getter tries to do None.__dict__.get(...) and crashes. The convention is: when obj is None, return the descriptor itself. That's why Order.price gives you the descriptor object and order.price gives you the value.

student (focused)

Right. Same pattern as yesterday — Order.price returns the class-level object, order.price triggers the protocol. Consistent everywhere.

teacher (neutral)

Consistent everywhere. That is the point of the data model. Once you know that attribute access, indexing, length, and iteration all run protocols, you can predict how any Python feature works from first principles. You don't have to memorise @property as a special case — you understand it as one instance of a general mechanism.

student (thinking)

So when Amir uses @property in the codebase, he's using the built-in descriptor. And if he ever writes a custom descriptor class, it's the same protocol — just a class with __get__ and __set__. I can read it now.

teacher (serious)

You can read it. And you can write one. The exercise puts you on the other side of the panel — building the lock instead of just turning the handle.

One thing to keep in mind: descriptors only work when they're defined on the class, not set on an instance. If you write order.price = ValidatedAttribute() after the fact, Python treats it as a plain instance dictionary assignment and never invokes the descriptor protocol. The class dictionary is where Python checks for descriptors first.

student (amused)

Of course. Because order.__dict__ is checked after the class, and the descriptor on the class intercepts first. Yesterday this would have confused me. Today it's just... the access order.

teacher (amused)

Six days ago you thought len() just called len(). Look at you now.

student (excited)

Okay. I'm ready to build ValidatedAttribute. Let me think: __set_name__ to capture the attribute name, __get__ to return the value from instance __dict__, __set__ to validate then store. That's the whole thing.

teacher (encouraging)

That is the whole thing. One more note before you write it: make your ValidatedAttribute accept a type_ argument and a validator function. That way a single class handles both price (must be positive float) and status (must be in valid set). The validator is a callable — lambda x: x > 0 for price, lambda x: x in VALID_STATUSES for status. The descriptor calls the validator in __set__ and raises ValueError if it returns False. Generic by design.

Next lesson we're going inside __dict__ itself — how Python stores your data in memory, what __slots__ does to that storage, and why a class with fifty @property descriptors might be a memory problem you didn't know you had.