The @property decorator lets a method be accessed like a plain attribute — no parentheses needed — enabling computed values and validated writes while keeping call sites clean.
class Circle:
def __init__(self, radius):
self._radius = radius # single underscore: "internal, by convention"
@property
def radius(self): # accessed as circle.radius, no ()
return self._radius
@radius.setter
def radius(self, value): # runs when someone does circle.radius = value
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self): # a read-only computed property, no setter defined
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.radius) # 5 — looks like a plain attribute access
print(c.area) # 78.53975 — computed on the fly, no () needed
c.radius = 10 # runs the setter, validates the new value
# c.radius = -5 # ❌ raises ValueError
Without @property, exposing validation on write would normally require explicit get_radius()/set_radius() methods (as in Java) — @property achieves the same validation while keeping the call site looking exactly like plain attribute access (c.radius, not c.get_radius()), which is more idiomatically Python.
A property with only a getter (like area above, with no @area.setter) is effectively read-only — attempting c.area = 100 raises an AttributeError. This pattern — a "computed" property with no backing field of its own — is common for values that are always derived from other attributes rather than stored directly.
Since Python has no true private attributes, _leading_underscore (convention: internal use) and __double_leading_underscore (triggers name mangling, making accidental access from outside the class harder, though still not impossible) are the two levels of "privacy" available, both relying on convention and discipline rather than compiler enforcement.