Property Decorators & Descriptors

intermediate property descriptors getter setter __get__ __set__

In Java, we write getX() and setX() methods everywhere. In Python, we use @property — it gives us the same control (validation, computed values, read-only attributes) but with clean attribute-style access. No getBalance() nonsense.

@property — Pythonic Getters

@property turns a method into something that looks like an attribute. We call it without parentheses.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):  # accessed as circle.area, not circle.area()
        return 3.14159 * self._radius ** 2

c = Circle(5)
print(c.area)  # 78.53975 — looks like an attribute
# c.area = 10  # AttributeError — it's read-only by default

Adding a Setter with @attr.setter

If we want to allow assignment, we define a setter.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius  # this calls the setter!

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):  # computed, read-only
        return self._celsius * 9/5 + 32

t = Temperature(100)
print(t.fahrenheit)  # 212.0
t.celsius = -300     # ValueError: Below absolute zero!

Notice: self.celsius = celsius in __init__ calls the setter, so validation runs on creation too.

Adding a Deleter with @attr.deleter

Rarely needed, but we can control what happens when someone does del obj.attr.

@celsius.deleter
def celsius(self):
    print("Resetting temperature")
    self._celsius = 0

Read-Only Properties

Any @property without a setter is automatically read-only. This is the simplest way to make an attribute that can’t be changed from outside.

class User:
    def __init__(self, first, last):
        self._first = first
        self._last = last

    @property
    def full_name(self):  # computed, read-only
        return f"{self._first} {self._last}"

u = User("Manish", "Prajapati")
print(u.full_name)    # Manish Prajapati
# u.full_name = "X"   # AttributeError — no setter defined

How property() Works Under the Hood

@property is just syntactic sugar for the property() built-in. These two are identical:

# Using decorator syntax
class C:
    @property
    def x(self):
        return self._x

# Using property() directly — same thing
class C:
    def _get_x(self):
        return self._x
    x = property(_get_x)

And property() itself is a descriptor. Which brings us to…

The Descriptor Protocol

A descriptor is any object that defines __get__, __set__, or __delete__. When Python accesses an attribute, it checks if the value stored on the class is a descriptor. If so, it calls the descriptor’s methods instead of returning the value directly.

In simple language, a descriptor is an object that intercepts attribute access and does something custom with it.

class Validated:
    """A descriptor that enforces a minimum value."""
    def __init__(self, min_value=0):
        self.min_value = min_value

    def __set_name__(self, owner, name):
        self.name = name          # the attribute name on the owner class

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self           # accessed on class, return descriptor
        return obj.__dict__.get(self.name, 0)

    def __set__(self, obj, value):
        if value < self.min_value:
            raise ValueError(f"{self.name} must be >= {self.min_value}")
        obj.__dict__[self.name] = value

class Product:
    price = Validated(min_value=0)   # descriptor instance on the class
    quantity = Validated(min_value=1)

p = Product()
p.price = 9.99     # calls Validated.__set__
p.quantity = 5
# p.price = -1     # ValueError: price must be >= 0

Data vs Non-Data Descriptors

This distinction matters for attribute lookup:

  • Data descriptor: defines __set__ (or __delete__). Takes priority over instance __dict__.
  • Non-data descriptor: defines only __get__. Instance __dict__ takes priority.

property is a data descriptor (it has __set__). Regular methods are non-data descriptors (they only have __get__).

The lookup order is: data descriptors > instance dict > non-data descriptors.

# Regular functions are non-data descriptors
# That's why we can shadow a method on an instance:
class Foo:
    def bar(self):
        return "method"

f = Foo()
f.bar = "instance attr"  # shadows the method
print(f.bar)  # "instance attr" — instance dict wins over non-data

__set_name__ Hook

Added in Python 3.6, __set_name__ is called automatically when the descriptor is assigned to a class attribute. It tells the descriptor what name it was given. We used it in the Validated example above — without it, we’d have to pass the name manually.

When to Use What

  • Simple computed attribute or read-only: @property.
  • Validation on a single attribute: @property with a setter.
  • Same validation logic on multiple attributes: write a custom descriptor (avoids duplicating the property code).

In simple language, @property is the Pythonic way to add getters and setters without ugly method calls. Under the hood, it uses the descriptor protocol — the same mechanism that powers methods, staticmethod, classmethod, and __slots__. When we need reusable validation across many attributes, writing a custom descriptor is the way to go.