You have a class. It has an attribute. You want to protect that attribute — maybe validate it, maybe log every write, maybe make it read-only after the first assignment. So you do what most developers do. You prefix with an underscore, write a get_temperature() method, write a set_temperature() method, and tell yourself that’s “Pythonic.”

It works. But every class that needs this pattern gets the same boilerplate. Every attribute that needs protection gets its own pair of methods. The class that started as twelve lines is now forty-five. And somewhere around the third get_x / set_x pair, you start feeling like you’re writing Java.

But there’s a deeper problem. The _variable + get_x() / set_x() pattern only protects the attribute if everyone uses the setter. The underscore is a convention, not a constraint. Any code — including your own — can write self._temperature = -300 directly, and Python will store it without complaint. The validation in set_temperature() never runs.

There’s a different way to think about this. Not a workaround — the actual mechanism Python uses internally.

Every attribute access is a question Python asks in a specific order

When you write obj.x, Python doesn’t just reach into a dictionary and hand you a value. It runs a negotiation. It asks the class — and the class’s class — whether anything wants to intercept this access before handing over the raw value. A lookup is passive — you ask, Python fetches. This is active — Python runs a sequence of checks, and any object that implements the right methods can plug into that sequence and change what gets returned.

That negotiation has a name: the descriptor protocol.

Think of your class like a wall with electrical outlets. Most outlets are plain: you plug something in, you get power. But some outlets have circuitry built into the wall itself. When you plug into one of those, the wall intercepts the connection, checks what you’re plugging in, and decides what to deliver. From the outside, it looks identical to a plain outlet. obj.x = value still looks like a plain assignment. But inside the wall, something is running.

A descriptor is that circuitry. It’s an object that lives as a class attribute and implements one or more of three special methods:

  • __get__ — called when the attribute is read
  • __set__ — called when the attribute is written
  • __delete__ — called when the attribute is deleted

If your object implements __get__ and __set__, Python will call those methods automatically whenever the attribute is accessed on any instance of the class that owns it. Not just one class. Any class. That’s the part that changes things.

What this looks like in practice

Say you’re building a sensor model. Temperature readings should be floats, and they should never go below absolute zero. With the classic approach, you’d write a get_temperature() and set_temperature() on every sensor class that needs this. With a descriptor, you write it once.

class ValidatedFloat:
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value
        self.name = None  # set by __set_name__

    def __set_name__(self, owner, name):
        # Python calls this when the descriptor is assigned to a class attribute
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # accessed on the class itself, not an instance
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        value = float(value)  # type coercion
        if self.min_value is not None and value < self.min_value:
            raise ValueError(
                f"{self.name} must be >= {self.min_value}, got {value}"
            )
        if self.max_value is not None and value > self.max_value:
            raise ValueError(
                f"{self.name} must be <= {self.max_value}, got {value}"
            )
        obj.__dict__[self.name] = value

There’s a subtle trap the first time you write a descriptor. Inside __set__, you might instinctively write self.temperature = value to store the result. Don’t. temperature is a descriptor on the class — so that assignment would trigger __set__ again, which would trigger it again, and you’d have infinite recursion before you stored a single value.

The escape hatch is obj.__dict__[self.name] = value. The descriptor protocol lives on the class, not the instance. Python checks the class for descriptors when you access an attribute, but obj.__dict__ is raw instance storage — writing there bypasses the protocol entirely. The circuitry is in the wall socket, not in the plug. If you wire directly to the copper behind the socket, you skip it.

Python separates class-level and instance-level storage deliberately. The class is shared across every instance — it’s where behavior lives. The instance dictionary is private to each object — it’s where state lives. Descriptors sit on the class so that one piece of behavior can govern every instance. But the actual values have to be stored per-instance, which is why obj.__dict__ is where the data lands. The class holds the rule. The instance holds the result.

That’s also why __get__ reads with obj.__dict__.get(self.name) rather than getattr(obj, self.name) — same reason, same trap.

Now attach it to a class:

class Sensor:
    temperature = ValidatedFloat(min_value=-273.15)
    humidity = ValidatedFloat(min_value=0, max_value=100)

    def __init__(self, temperature, humidity):
        self.temperature = temperature
        self.humidity = humidity

Now try it:

s = Sensor(temperature=22.5, humidity=60)
print(s.temperature)  # 22.5

s.temperature = -300  # raises ValueError: temperature must be >= -273.15
s.humidity = 110      # raises ValueError: humidity must be <= 100

The assignment s.temperature = -300 looks exactly like a plain attribute write. There’s no set_temperature() call in sight. But Python saw that temperature on the Sensor class is a descriptor — an object with a __set__ method — and routed the assignment through it automatically.

A descriptor closes the gap that _variable leaves open. __set__ is wired into the assignment operator itself. There is no way to write obj.temperature = -300 and bypass it — not from outside the class, not from inside it. The protection isn’t in a method someone has to remember to call. It’s in the protocol.

ValidatedFloat is now reusable across every class that needs validated numeric attributes. Write it once. Attach it anywhere. Validation, type coercion, audit logging — any behavior that should run on every read or write of an attribute — can live in a descriptor. The behavior travels with the protocol, not with the class.

The lookup chain, briefly

When you access obj.x, Python’s lookup order is roughly:

  1. Data descriptors on the class (objects with both __get__ and __set__)
  2. Instance __dict__
  3. Non-data descriptors and other class attributes (objects with only __get__)

This ordering matters. A data descriptor — one that defines __set__ — takes priority over the instance dictionary. That’s what makes it a real access control mechanism and not just a suggestion.

What changes now

This is not a niche feature you reach for in advanced situations. It is the mechanism Python itself is built on.

Consider @property — the decorator most Python developers use when they first want controlled attribute access. It looks like language syntax. It isn’t. It’s a descriptor that ships with Python, one that stores a getter function in __get__ and an optional setter in __set__. When you write @property, you’re not activating a special compiler feature. You’re attaching a pre-built descriptor to your class, the same way you just attached ValidatedFloat.

classmethod and staticmethod work the same way. They’re descriptors whose __get__ returns a different kind of callable depending on whether you accessed them from the class or from an instance. The decorator syntax hides that, but the protocol underneath is identical.

The entire attribute model of Python is this negotiation. You were already inside it.


Which raises a question worth sitting with: if the language was already giving you this mechanism, what other “boilerplate” patterns in your codebase are actually descriptors waiting to be extracted?