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:
@propertywith 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.