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:
- Data descriptors on the class (objects with both
__get__and__set__) - Instance
__dict__ - 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?