How does the `@property` decorator work in Python, and when should you prefer it over a plain attribute?
`@property` turns a method into a descriptor that Python calls automatically on attribute access, letting you add validation or computation behind a dot-access interface without changing callers. Use it when a value is derived, needs guarding, or must be lazily computed — not as a default for every attribute.
How to think about it
When an interviewer asks about @property, they are rarely interested in the syntax. They want to know whether you grasp a deeper Python idea — that attribute access can be intercepted — and whether you have the taste to use that power sparingly.
Here is the problem it solves. You ship a class with a plain radius attribute, and callers happily write c.radius = 5. Months later you need to reject negative radii. Without @property, your only escape is to rename the attribute to get_radius() and set_radius() — and now every line that ever touched .radius, in your code and everyone else’s, has to change. @property removes that wall: the dot-access interface stays exactly as it was, and the validation slips in quietly behind it.
A worked example
A property turns a method into something Python calls for you on access. Watch what c.radius = -3 does at the very end:
import math
class Circle:
def __init__(self, radius):
self.radius = radius # already runs the setter below
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError(f"radius must be >= 0, got {value}")
self._radius = value
@property
def area(self):
return math.pi * self._radius ** 2
@property
def diameter(self):
return self._radius * 2
c = Circle(5)
print("radius :", c.radius)
print("diameter:", c.diameter)
print("area :", round(c.area, 4))
c.radius = 10
print("new area:", round(c.area, 4))
try:
c.radius = -3
except ValueError as e:
print("Caught:", e)
radius : 5
diameter: 10
area : 78.5398
new area: 314.1593
Caught: radius must be >= 0, got -3
Notice that area and diameter store nothing — they are computed fresh from _radius every time they are read. And because neither defines a setter, c.area = 10 would raise AttributeError: a read-only attribute, for free.
How it works under the hood
@property builds a descriptor and attaches it to the class, not the instance. From then on Python routes every access through it:
c.radius → Circle.radius.__get__(c, Circle) → return c._radius
c.radius = 10 → Circle.radius.__set__(c, 10) → validate, then store
The real value lives in the plain _radius; the property is just the gate every read and write has to pass through. That is the whole trick.
When to reach for it — and when not to
Reach for @property when a value is derived (area), needs guarding (a non-negative radius), or should be computed lazily the first time it is asked for. Reserve it for those cases. Wrapping every ordinary attribute in a property “just in case” buys you nothing but indirection and a slower dot-access — the Pythonic default is a plain attribute until you have a concrete reason to intercept it.