Python

Descriptors and property — the real mechanism behind @property

@property looks like a special, built-in language feature, but it's actually a thin, ordinary wrapper around a more general protocol — the descriptor protocol — the exact same mechanism that also powers instance methods, staticmethod, and classmethod underneath.

Advanced

3 min read

@property: computed access that looks like a plain attribute

class Circle:
    def __init__(self, radius):
        self.radius = radius
 
    @property
    def area(self):
        return 3.14159 * self.radius ** 2
 
c = Circle(5)
print(c.area)  # 78.53975 — reads like an attribute, but genuinely RUNS a computation
c.area = 100    # AttributeError — no setter defined, so this attribute is read-only

@property lets a method be accessed using plain attribute syntax (c.area, no parentheses) while it actually runs real code underneath — recomputing a derived value on every access, in this case. Without an explicit setter (@area.setter), the property is read-only, and attempting to assign to it raises AttributeError — a real, deliberate restriction, not a bug.

What @property actually is: an instance of the descriptor protocol

class Property:  # a SIMPLIFIED version of what @property actually does underneath
    def __init__(self, getter):
        self.getter = getter
 
    def __get__(self, instance, owner):  # THIS is the descriptor protocol's core method
        if instance is None:
            return self
        return self.getter(instance)
 
class Circle:
    def __init__(self, radius):
        self.radius = radius
 
    @Property  # using the hand-rolled version above — behaves identically to @property
    def area(self):
        return 3.14159 * self.radius ** 2

@property is real Python, not special compiler magic — it's an object that implements __get__ (and optionally __set__/__delete__), and Python's attribute-lookup machinery specifically checks for these methods on a class attribute before falling back to a plain instance-dictionary lookup. This is the descriptor protocol: any object with __get__ defined on its class is a descriptor, and accessing it through an instance calls __get__ instead of just returning the object itself — @property is simply the standard library's ready-made, convenient descriptor for exactly this "computed attribute" pattern.

A full read/write property, with real validation logic

class Circle:
    def __init__(self, radius):
        self.radius = radius  # this line actually calls the SETTER below, not a plain assignment
 
    @property
    def radius(self):
        return self._radius
 
    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("radius cannot be negative")
        self._radius = value  # the REAL underlying storage, with a different name to avoid recursion
 
c = Circle(5)
c.radius = -1  # ValueError — the setter's validation runs on EVERY assignment, not just construction

self.radius = radius inside __init__ doesn't bypass the property — it calls the setter, exactly like any other assignment to radius would, which is precisely why the validation runs even during construction, not just on later reassignment. The actual value has to live under a different name (_radius here, by convention) specifically because self.radius = value inside the setter itself would call the setter again, recursing infinitely — a real, common mistake when first writing a property with both a getter and setter.

Why descriptors matter beyond @property: they're the mechanism behind methods themselves

# A regular function becomes a BOUND method specifically because functions
# implement __get__ too — this is the actual mechanism behind instance.method()
# automatically receiving `self`, not special-cased syntax elsewhere in the language
class Example:
    def greet(self):
        return "hi"
 
e = Example()
e.greet  # <bound method Example.greet of <Example object>> — __get__ already ran, binding `self`

The exact same descriptor protocol that powers @property is also what makes instance methods work at all: a plain function object implements __get__, and when accessed through an instance, that __get__ returns a bound method — a version of the function with self already filled in. This is worth knowing explicitly: @property, @staticmethod, @classmethod, and ordinary instance methods aren't four unrelated language features — they're four different descriptor implementations, all going through the exact same attribute-lookup mechanism.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What is @property actually built on, mechanically?

2. Why does `self.radius = radius` inside __init__ trigger a property's setter validation, rather than bypassing it?

3. How are @property, @staticmethod, @classmethod, and ordinary instance methods actually related?