The earlier encapsulation article introduced python @property decorator basics — this one goes deeper into python property getter setter syntax specifically: the underlying property() function it's built on, computed and read-only properties, the classic infinite-recursion bug, and functools.cached_property for expensive computed values.
Introduction: Why @property Exists
The problem it solves
Imagine starting a class with a plain, simple public attribute:
class Celsius:
def __init__(self, temperature):
self.temperature = temperature
temp = Celsius(25)
print(temp.temperature) # 25Later, you realize you need validation — temperature can't reasonably go below absolute zero. The naive fix would be converting temperature into a private attribute with explicit get_temperature()/set_temperature() methods — but that breaks every single piece of existing code that was written to access .temperature directly as a plain attribute.
Python's design principle
@property solves this precisely: accessing a property and accessing a plain attribute look identical from the outside — no parentheses, no visible difference at all. This means you can start with a plain attribute, and later convert it into a property with validation or computed logic, without breaking any existing calling code whatsoever.
class Celsius:
def __init__(self, temperature):
self._temperature = temperature
@property
def temperature(self):
return self._temperature
@temperature.setter
def temperature(self, value):
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._temperature = value
temp = Celsius(25)
print(temp.temperature) # 25 — still looks exactly like plain attribute access
temp.temperature = -300
# ValueError: Temperature cannot be below absolute zeroEvery line of code that was already using temp.temperature continues working completely unmodified — the validation logic was added purely internally, with zero visible change to the public interface.
Basic Getter, Setter, and Deleter Syntax
The getter: @property
class Celsius:
def __init__(self, temperature):
self._temperature = temperature
@property
def temperature(self):
return self._temperature@property turns the temperature method into a getter — accessed without parentheses, exactly like a plain attribute: temp.temperature, not temp.temperature().
The setter: @name.setter
@temperature.setter
def temperature(self, value):
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._temperature = value@temperature.setter adds a write handler, called whenever someone does temp.temperature = value — genuinely useful for validation, type coercion, or triggering some other side effect at the moment a value is assigned.
The deleter: @name.deleter
This is the least commonly used of the three, but it exists for cleanup logic when del obj.name is called:
@temperature.deleter
def temperature(self):
print("Deleting temperature")
del self._temperature
temp = Celsius(25)
del temp.temperature
# Deleting temperatureThe underscore-prefixed storage convention
Notice the pattern across all three: the actual data lives in self._temperature — a "private," underscore-prefixed attribute (per the naming convention covered in the earlier encapsulation article) — while the property methods (temperature, without the underscore) manage access to it. This is the standard, essentially universal convention for working with @property: store the real value under a distinct internal name, and expose the public-facing property under the clean name callers actually use.
Read-Only and Computed Properties
Read-only properties
Defining only a getter — no corresponding @name.setter — creates a property that can't be reassigned from outside the class at all:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
circle = Circle(5)
print(circle.area) # 78.53975
circle.area = 100
# AttributeError: property 'area' of 'Circle' object has no setterComputed properties
area here isn't stored anywhere directly — it's computed on the fly, derived from self.radius every single time it's accessed, rather than calculated once and cached. This is exactly what makes it a genuinely good fit for a read-only property: there's no sensible way to "set" a value that's purely derived from other data, so no setter is defined, and Python correctly rejects any attempt to assign to it directly.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return self.celsius * 9 / 5 + 32
temp = Temperature(25)
print(temp.fahrenheit) # 77.0
temp.celsius = 30
print(temp.fahrenheit) # 86.0 — recomputed automatically, reflecting the updated celsiusBecause fahrenheit is computed fresh every time it's accessed, it automatically stays consistent with celsius — there's no risk of the two values drifting out of sync, which could easily happen if fahrenheit were instead stored as a separate attribute that needed to be manually kept up to date.
A practical example: a Product summary
class Product:
def __init__(self, name, price, in_stock):
self.name = name
self.price = price
self.in_stock = in_stock
@property
def summary(self):
status = "In Stock" if self.in_stock else "Out of Stock"
return f"{self.name} — )"
product = Product("Widget", 19.99, True)
print(product.summary) # Widget — $19.99 (In Stock)summary is a read-only property, computed fresh from name, price, and in_stock on every access — there's genuinely no separate "summary" data to store or set independently; it's always derived directly from the object's other, more fundamental attributes.
4. The Underlying property() Function and Common Pitfalls
@property is syntactic sugar for property()
The @property decorator syntax is really just a more readable way of using the built-in property(fget, fset, fdel) function directly. Here's the equivalent, non-decorator form, for clarity:
class Celsius:
def __init__(self, temperature):
self._temperature = temperature
def get_temperature(self):
return self._temperature
def set_temperature(self, value):
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._temperature = value
temperature = property(get_temperature, set_temperature)Both versions behave identically — temp.temperature and temp.temperature = value work exactly the same way in either form. The @property/@x.setter decorator syntax is simply the more concise, more readable way of writing the same underlying mechanism, and it's what you'll see in essentially all modern Python code.
The classic infinite-recursion bug
This is a genuinely common mistake, and it produces a confusing error the first time you hit it: naming the internal storage variable the same as the property itself, rather than using a distinct underscore-prefixed name:
class Celsius:
def __init__(self, temperature):
self.temperature = temperature # this calls the setter below!
@property
def temperature(self):
return self.temperature # calls itself — infinite recursion!
@temperature.setter
def temperature(self, value):
self.temperature = value # calls itself too — also infinite recursion!temp = Celsius(25)
# RecursionError: maximum recursion depth exceededThe bug here: inside the getter, return self.temperature doesn't return some separately stored value — it triggers the getter itself again, since self.temperature always routes through the property, never directly to any underlying stored data. The same problem hits the setter: self.temperature = value calls the setter itself again, rather than storing the value anywhere. Both methods end up calling themselves forever, exactly matching the recursion behavior covered in the earlier recursion article — except here, there's no base case at all, since the "storage" was never actually separate from the property in the first place.
The fix: always use a distinct, underscore-prefixed name for the actual storage, exactly as shown throughout this article — self._temperature, never self.temperature, inside the getter and setter bodies.
Guidance against overusing properties
Not every attribute needs to become a property. If there's no genuine validation, computation, or side effect involved, a plain public attribute remains the simpler, equally correct choice — exactly the guidance given in the earlier encapsulation article. Additionally, be cautious about hiding a genuinely expensive operation — a database call, a network request, a heavy calculation — behind what looks like simple, free attribute access via @property. A method call, like product.fetch_reviews(), visually signals to the caller that there's likely some real cost involved; product.reviews, accessed as a property, looks free and instantaneous, even if it secretly triggers an expensive database query every single time it's touched. When an operation has a genuine, non-trivial cost, an explicit method is usually the more honest, more appropriate choice than a property that quietly hides that cost behind innocent-looking attribute syntax.
Caching Expensive Properties with functools.cached_property
The problem it solves
Sometimes a computed property genuinely is expensive, but its underlying data doesn't change often (or at all) after the object is created — recomputing it from scratch on every single access is wasteful:
import time
class Report:
def __init__(self, data):
self.data = data
@property
def summary(self):
time.sleep(2) # simulating an expensive computation
return sum(self.data)
report = Report([1, 2, 3, 4, 5])
print(report.summary) # takes 2 seconds
print(report.summary) # takes ANOTHER 2 seconds — recomputed from scratch every timeThe fix: functools.cached_property
functools.cached_property computes the value exactly once, on first access, and then reuses that cached result for every subsequent access:
from functools import cached_property
import time
class Report:
def __init__(self, data):
self.data = data
@cached_property
def summary(self):
time.sleep(2) # simulating an expensive computation
return sum(self.data)
report = Report([1, 2, 3, 4, 5])
print(report.summary) # takes 2 seconds — computed once
print(report.summary) # instant — reused from cacheHow it works mechanically
Unlike a regular @property, which always re-runs its method on every access, cached_property stores the computed result directly in the instance's __dict__, under the same attribute name, after it's computed the first time. Every subsequent access finds that value sitting directly in the instance's own __dict__ and returns it immediately, without ever calling the underlying method again at all.
Key limitations worth flagging
Requires a writable instance
__dict__.cached_propertyworks by writing the cached value directly intoself.__dict__, so it won't work with classes that use__slots__to restrict which attributes an instance can hold — a more advanced optimization technique covered in a later article in this series, but worth flagging here as a genuine incompatibility.Not automatically thread-safe for concurrent first access. If multiple threads happen to access a
cached_propertyfor the very first time simultaneously, before any value has been cached yet, the underlying computation could genuinely run more than once, in a race condition —cached_propertyitself doesn't include any locking to prevent this. In multi-threaded contexts where this genuinely matters, you'd need to add your own explicit locking around the first access.
For everyday, single-threaded use — the overwhelming majority of scripts and applications — cached_property is a simple, genuinely effective way to avoid unnecessary repeated computation, without needing to manually manage a separate cache attribute and the bookkeeping that would otherwise require.