The @property Decorator in Python

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)   # 25

Later, 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 zero

Every 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 temperature
The 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 setter
Computed 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 celsius

Because 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 exceeded

The 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 time
The 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 cache
How 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_property works by writing the cached value directly into self.__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_property for 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_property itself 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.