Skip to content
Hello Python
2/7

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.

Python example
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().

Python example
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.0

What 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.

Python example
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 problem
LRU 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 problem

Concept checks

Q01

What is a descriptor, and what methods define one?

Q02

How does @property relate to descriptors?

Q03

What's the difference between a data descriptor and a non-data descriptor, and why does it matter?