Descriptors
Topic 2 of 7, with 3 concept checks. The protocol behind @property, methods, and attribute access
Control attribute access at the class boundary
Attribute protocol
Follow descriptor lookup precedence and binding behavior so properties, methods, validation, and managed fields can be explained without treating attribute access as magic.
Core lesson 01
A descriptor is any object whose class defines __get__ (and optionally __set__/__delete__); set as a class attribute, it intercepts attribute access on instances, running custom logic instead of a plain lookup.
Normally, obj.attr just looks up attr in obj's (or its class's) __dict__ and returns it. If the value found on the CLASS is a descriptor (its type defines __get__), Python calls descriptor.__get__(obj, type(obj)) instead of returning it directly. This is the mechanism underneath @property, and also underneath how ordinary methods work — a function is itself a descriptor, which is how obj.method() ends up bound to obj automatically.
class Celsius:
def __get__(self, obj, objtype=None):
return obj._celsius
def __set__(self, obj, value):
if value < -273.15:
raise ValueError("below absolute zero")
obj._celsius = value
class Weather:
temperature = Celsius() # descriptor as a class attribute
w = Weather()
w.temperature = 22
print(w.temperature) # 22 -- goes through __get__
w.temperature = -300 # raises ValueError -- goes through __set__What to remember
What is a descriptor, and what methods define one?
Common footguns
- Storing the value directly on the descriptor instance (self.value = value inside __set__) instead of on the owning object — since one descriptor instance is shared by the whole class, that leaks state across every instance using it.
Core lesson 02
@property is a built-in descriptor factory: it turns a method into a computed attribute, using __get__ (and __set__ if you add a .setter) under the hood.
Writing a full descriptor class for every computed attribute would be verbose. property packages that pattern: @property marks a method as the getter, @x.setter marks another as the setter, and Python's property class implements __get__/__set__ to call them at the right time. It's the pythonic way to add validation or computed values to attribute access without changing the public interface — callers still write obj.x, not obj.get_x().
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self._celsius = (value - 32) * 5/9
t = Temperature(0)
print(t.fahrenheit) # 32.0
t.fahrenheit = 212
print(t._celsius) # 100.0What to remember
How does @property relate to descriptors?
Common footguns
- Adding expensive computation inside a @property getter — callers expect attribute access to be cheap; a slow property is a surprising performance trap.
Core lesson 03
A data descriptor (defines __set__ or __delete__) always takes priority over an instance's own __dict__ entry. A non-data descriptor (only __get__, like a plain method) is overridden by an instance attribute of the same name.
This priority order explains a subtlety: since @property is a data descriptor, you can never accidentally shadow it by setting self.x = value in __init__ if x is a property — the property's __set__ wins (or raises, if no setter). But a plain method (a non-data descriptor) CAN be shadowed by an instance attribute with the same name, since instance __dict__ wins over non-data descriptors.
class Example:
def method(self):
return "from class"
e = Example()
e.method = lambda: "from instance"
print(e.method()) # 'from instance' -- non-data descriptor, instance wins
class WithProperty:
@property
def x(self):
return "from property"
wp = WithProperty()
try:
wp.x = "trying to shadow" # raises -- property has no setter, data descriptor wins
except AttributeError as err:
print("blocked:", err)What to remember
What's the difference between a data descriptor and a non-data descriptor, and why does it matter?
Common footguns
- Being surprised that a method can be silently overridden per-instance while a property cannot — a direct consequence of the data vs non-data descriptor distinction.
Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Use @property for computed or validated attributes instead of custom __get__/__set__ classes, unless reusing logic across many unrelated classes.
- Keep property getters cheap — move expensive work to an explicit method instead.
- Store descriptor state on the owning instance, never on the descriptor instance itself.
- Reach for __slots__ alongside descriptors when building many lightweight attribute-heavy objects.
Apply the concept in Interview practice
Design HashSeteasyLeetCode #705 · O(1) average per operation
Build a hash set from scratch using an array of buckets, practicing the same attribute-access and encapsulation design thinking descriptors formalize.
Open problemLRU CachemediumLeetCode #146 · O(1) get/put
Revisit this from a design-discipline angle: getters/setters here must keep two internal structures (dict + linked list) perfectly in sync on every access, the same rigor descriptors formalize for attribute access.
Open problemConcept checks
What is a descriptor, and what methods define one?
Hint
It's an object that customizes what happens when an attribute is accessed.
Look for __get__, __set__, and/or __delete__ defined on the attribute's type.
Answer
A descriptor is any object whose class defines __get__ (and optionally __set__/__delete__); set as a class attribute, it intercepts attribute access on instances, running custom logic instead of a plain lookup.
Normally, obj.attr just looks up attr in obj's (or its class's) __dict__ and returns it. If the value found on the CLASS is a descriptor (its type defines __get__), Python calls descriptor.__get__(obj, type(obj)) instead of returning it directly. This is the mechanism underneath @property, and also underneath how ordinary methods work — a function is itself a descriptor, which is how obj.method() ends up bound to obj automatically.
class Celsius:
def __get__(self, obj, objtype=None):
return obj._celsius
def __set__(self, obj, value):
if value < -273.15:
raise ValueError("below absolute zero")
obj._celsius = value
class Weather:
temperature = Celsius() # descriptor as a class attribute
w = Weather()
w.temperature = 22
print(w.temperature) # 22 -- goes through __get__
w.temperature = -300 # raises ValueError -- goes through __set__Watch out
- Storing the value directly on the descriptor instance (self.value = value inside __set__) instead of on the owning object — since one descriptor instance is shared by the whole class, that leaks state across every instance using it.
How does @property relate to descriptors?
Hint
property is itself implemented as a descriptor.
It's the built-in, convenient way to create one without writing a separate class.
Answer
@property is a built-in descriptor factory: it turns a method into a computed attribute, using __get__ (and __set__ if you add a .setter) under the hood.
Writing a full descriptor class for every computed attribute would be verbose. property packages that pattern: @property marks a method as the getter, @x.setter marks another as the setter, and Python's property class implements __get__/__set__ to call them at the right time. It's the pythonic way to add validation or computed values to attribute access without changing the public interface — callers still write obj.x, not obj.get_x().
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self._celsius = (value - 32) * 5/9
t = Temperature(0)
print(t.fahrenheit) # 32.0
t.fahrenheit = 212
print(t._celsius) # 100.0Watch out
- Adding expensive computation inside a @property getter — callers expect attribute access to be cheap; a slow property is a surprising performance trap.
What's the difference between a data descriptor and a non-data descriptor, and why does it matter?
Hint
One defines __set__ (or __delete__), the other only __get__.
It changes who wins when both an instance attribute and a descriptor share a name.
Answer
A data descriptor (defines __set__ or __delete__) always takes priority over an instance's own __dict__ entry. A non-data descriptor (only __get__, like a plain method) is overridden by an instance attribute of the same name.
This priority order explains a subtlety: since @property is a data descriptor, you can never accidentally shadow it by setting self.x = value in __init__ if x is a property — the property's __set__ wins (or raises, if no setter). But a plain method (a non-data descriptor) CAN be shadowed by an instance attribute with the same name, since instance __dict__ wins over non-data descriptors.
class Example:
def method(self):
return "from class"
e = Example()
e.method = lambda: "from instance"
print(e.method()) # 'from instance' -- non-data descriptor, instance wins
class WithProperty:
@property
def x(self):
return "from property"
wp = WithProperty()
try:
wp.x = "trying to shadow" # raises -- property has no setter, data descriptor wins
except AttributeError as err:
print("blocked:", err)Watch out
- Being surprised that a method can be silently overridden per-instance while a property cannot — a direct consequence of the data vs non-data descriptor distinction.