Sometimes a class has an attribute that's derived from other attributes:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12 — no parenthesesr.area without parens — but area is a method?
That's what @property does. It turns a method into something that looks like an attribute. When someone reads r.area, Python actually calls Rectangle.area(r) and returns the result. The caller doesn't know — and doesn't need to know — whether area is stored or computed.
Why hide it?
Two reasons. (1) Consistency. Calling code reads r.area, r.width, r.height — same syntax. The fact that one is computed and two are stored is an implementation detail. (2) Encapsulation. If you start with self.area = w * h in __init__, then later realise you need to recompute it when width changes, switching to @property lets you do that without breaking the calling code. The attribute access stays the same.
Can you assign to it?
Not by default. r.area = 99 raises AttributeError. To allow writes, you add a setter:
@area.setter
def area(self, value):
# validate or compute, then assign to internals
...Most properties are read-only. If you find yourself reaching for setters often, consider whether a regular method (set_area) would be clearer.
@property — methods that look like attributesclass Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
return self.width * self.height
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
r = Rectangle(3, 4)
print(r.area) # 12
print(r.perimeter) # 14Without @property | With @property |
|---|---|
r.area() (call syntax) | r.area (attribute syntax) |
| Caller knows it's computed | Caller doesn't have to know |
| Migrating to/from stored is breaking | Migrate freely — call site doesn't change |
r = Rectangle(3, 4)
r.area = 99 # AttributeError: can't set attributeGood — derived values usually shouldn't be set directly. If you want to allow it, add a setter:
class Celsius:
def __init__(self, value: float):
self._value = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float):
if v < -273.15:
raise ValueError("below absolute zero")
self._value = v
c = Celsius(20)
print(c.value) # 20
c.value = 30 # uses the setter — validates firstThe _value (leading underscore) is a Python convention for "this is internal — don't access from outside." The public .value property mediates access.
@propertyYes:
area, full_name, is_active)No:
compute_x() so the cost is visible@dataclass and you just want a stored field — leave it as a regular field@functools.cached_propertyFor a property that's expensive but immutable for the lifetime of the instance:
from functools import cached_property
class Document:
def __init__(self, text: str):
self.text = text
@cached_property
def word_count(self) -> int:
# computed once, then cached on the instance
return len(self.text.split())First access computes; subsequent accesses return the cached value.
Sometimes a class has an attribute that's derived from other attributes:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12 — no parenthesesr.area without parens — but area is a method?
That's what @property does. It turns a method into something that looks like an attribute. When someone reads r.area, Python actually calls Rectangle.area(r) and returns the result. The caller doesn't know — and doesn't need to know — whether area is stored or computed.
Why hide it?
Two reasons. (1) Consistency. Calling code reads r.area, r.width, r.height — same syntax. The fact that one is computed and two are stored is an implementation detail. (2) Encapsulation. If you start with self.area = w * h in __init__, then later realise you need to recompute it when width changes, switching to @property lets you do that without breaking the calling code. The attribute access stays the same.
Can you assign to it?
Not by default. r.area = 99 raises AttributeError. To allow writes, you add a setter:
@area.setter
def area(self, value):
# validate or compute, then assign to internals
...Most properties are read-only. If you find yourself reaching for setters often, consider whether a regular method (set_area) would be clearer.
@property — methods that look like attributesclass Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
return self.width * self.height
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
r = Rectangle(3, 4)
print(r.area) # 12
print(r.perimeter) # 14Without @property | With @property |
|---|---|
r.area() (call syntax) | r.area (attribute syntax) |
| Caller knows it's computed | Caller doesn't have to know |
| Migrating to/from stored is breaking | Migrate freely — call site doesn't change |
r = Rectangle(3, 4)
r.area = 99 # AttributeError: can't set attributeGood — derived values usually shouldn't be set directly. If you want to allow it, add a setter:
class Celsius:
def __init__(self, value: float):
self._value = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float):
if v < -273.15:
raise ValueError("below absolute zero")
self._value = v
c = Celsius(20)
print(c.value) # 20
c.value = 30 # uses the setter — validates firstThe _value (leading underscore) is a Python convention for "this is internal — don't access from outside." The public .value property mediates access.
@propertyYes:
area, full_name, is_active)No:
compute_x() so the cost is visible@dataclass and you just want a stored field — leave it as a regular field@functools.cached_propertyFor a property that's expensive but immutable for the lifetime of the instance:
from functools import cached_property
class Document:
def __init__(self, text: str):
self.text = text
@cached_property
def word_count(self) -> int:
# computed once, then cached on the instance
return len(self.text.split())First access computes; subsequent accesses return the cached value.
Create a free account to get started. Paid plans unlock all tracks.