Python 3.14 ·
✓ verified by execution on 2026-07-30
Have you ever wondered how Python’s @property decorator actually works behind the scenes? It turns out that it is built on a powerful, lower-level feature called descriptors.
A descriptor is any object that defines the methods __get__(), __set__(), or __delete__(). When a class attribute is a descriptor, its special behavior overrides standard attribute lookup. Descriptors let objects customize how their attributes are looked up, stored, and deleted.
The Attribute Lookup Process
When you access an attribute like m.x, Python usually looks in the instance dictionary m.__dict__. However, if x is defined as a descriptor on the class, Python intercepts the lookup and invokes the descriptor’s __get__ method instead.
Here is a visual step-through of how the Python attribute lookup chain consults descriptors:
python · visualize
class Descriptor: def __get__(self, obj, type=None): return 42class MyClass: x = Descriptor()m = MyClass()print(m.x)
In this visualization, notice how m.x doesn’t just read a value from memory; it actively calls the __get__ method on the Descriptor instance!
Basic Descriptors
Let’s look at a simple descriptor that always returns the constant 10. To use a descriptor, you must instantiate it as a class attribute (not an instance attribute).
python
class Ten: def __get__(self, obj, objtype=None): return 10class A: y = Ten()a = A()print(a.y)
Output
10
Your output
The key difference between a normal class attribute and a descriptor is that the descriptor runs code dynamically.
python
class Ten: def __get__(self, obj, objtype=None): return 10class A: x = 5 y = Ten()a = A()print(a.x)print(a.y)
Output
5
10
Your output
Dynamic Lookups
Since __get__ is a function, you can run computations inside it. The value returned by a descriptor isn’t stored in the instance dictionary; it is computed on demand. Here’s a descriptor that dynamically calculates the length of a container.
The @property decorator is actually a built-in descriptor! Under the hood, it creates a descriptor object whose __get__ method calls the function you decorated.
python
class Temperature: def __init__(self, c): self._c = c @property def fahrenheit(self): return self._c * 9/5 + 32t = Temperature(0)print(t.fahrenheit)
Output
32.0
Your output
Data vs. Non-Data Descriptors
There are two kinds of descriptors:
Non-data descriptors: Define only __get__(). They are typically used for methods. If an instance has an attribute in its dictionary with the same name, the instance dictionary takes precedence.
Data descriptors: Define __set__() or __delete__(). If an instance has an attribute with the same name, the data descriptor takes precedence over the instance dictionary!
Validating with __set__ and __set_name__
When you want to control how an attribute is stored, you use __set__. But where do you store the value? A modern and clean way is to use __set_name__ (introduced in Python 3.6). It automatically tells the descriptor the name of the attribute it was assigned to in the class, simplifying state storage.
Here is a data descriptor that uses __set_name__ to learn its name (price) and validates that the value is not negative before saving it to the instance dictionary:
python
class Validator: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): return obj.__dict__.get(self.name) def __set__(self, obj, value): if value < 0: raise ValueError('Cannot be negative') obj.__dict__[self.name] = valueclass Item: price = Validator()i = Item()i.price = 10print(i.price)
Output
10
Your output
If we try to set a negative value, our validation logic catches it and raises an exception:
class Validator: def __set_name__(self, owner, name): self.name = name def __set__(self, obj, value): if value < 0: raise ValueError('Cannot be negative') obj.__dict__[self.name] = valueclass Item: price = Validator()i = Item()i.price = -5
By mastering descriptors, you gain deep insight into Python’s internals. Almost all of Python’s class features—methods, properties, classmethods, and staticmethods—are implemented using the descriptor protocol!
Check yourself
What makes a class a descriptor?
Reveal answer
Defining any of __get__, __set__ or __delete__ — The descriptor protocol is duck-typed: implement __get__/__set__/__delete__ and Python's attribute machinery uses it.
Where must a descriptor live to work at all?
Reveal answer
On the class, as a class attribute — Descriptors are only invoked when found on the class. Assign one to an instance attribute and it is just an ordinary object.
What does @property let you avoid?
Reveal answer
Writing getter and setter calls at every call site — @property keeps plain attribute syntax (obj.x) while running your code, so you can add validation without changing any caller.
A data descriptor defines __set__. What does that change?
Reveal answer
It takes priority over the instance __dict__ during lookup — Data descriptors win over instance attributes; non-data descriptors (only __get__) lose to them. That ordering is the subtle part.
Challenges
Challenge 1 +100 XP
Create a `ReadOnly` descriptor that allows a value to be set once, but raises an AttributeError if set again. Then use it in a class `Config` for the `db_url` attribute.
python
Test 1 — expects ""
Need a hint? (−25% XP)
Check if the attribute name is already in obj.__dict__ inside __set__.
Show solution (0 XP)
class ReadOnly: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): return obj.__dict__.get(self.name) def __set__(self, obj, value): if self.name in obj.__dict__: raise AttributeError('Read only') obj.__dict__[self.name] = valueclass Config: db_url = ReadOnly()
Challenge 2 +100 XP
Create a `Celsius` class with a `temperature` property. When getting it, return the value. When setting it, if the value is below -273.15, raise a ValueError.
python
Test 1 — expects ""
Need a hint? (−25% XP)
Use @property for the getter and @temperature.setter for the setter.
Show solution (0 XP)
class Celsius: def __init__(self, temperature): self.temperature = temperature @property def temperature(self): return self._temperature @temperature.setter def temperature(self, value): if value < -273.15: raise ValueError('Too cold') self._temperature = value