Skip to content

Properties

A plain attribute gives anyone direct access to read or write a value, no questions asked. @property lets you intercept that access and add logic in between: validate a value before storing it, compute it on the fly instead of storing it at all, or make it read-only by defining a getter but no setter.

From the outside the attribute looks and feels exactly the same, c.radius not c.get_radius(). The caller never knows there’s logic running underneath. That’s the point: the interface stays clean while the implementation gains control.

class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # Getter
return self._radius
@radius.setter
def radius(self, value): # Setter with validation
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self): # Computed property (no setter needed)
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.radius) # 5 ← calls getter
print(c.area) # 78.53 ← computed on the fly
c.radius = 10 # calls setter
# c.radius = -1 # ValueError: Radius cannot be negative

:::note[celsius vs _celsius] t.celsius and t._celsius always return the same value — celsius is just a clean public interface that reads from _celsius under the hood.

t.celsius → triggers the getter → returns self._celsius
t._celsius → direct access to the private attribute
  • t.celsius — the public door — goes through the getter, which could have logic in it
  • t._celsius — the back door — bypasses the getter, direct access

:::

Under the hood: a concrete mental model

Let me walk through it step by step with a concrete mental model.

Think of _celsius as a box and celsius as the window to that box

┌─────────────────────────────┐
Temperature object
_celsius = 100.0 box
celsius window to box
└─────────────────────────────┘

_celsius is

  • where the value physically lives in memory
  • the property, a controlled way to read from and write to that box.
Three different ways to interact with the value
Section titled “Three different ways to interact with the value”
t = Temperature(100)
# 1 — write through the public window (setter runs)
t.celsius = 50
# → setter checks value >= -273.15
# → stores 50 into self._celsius
# 2 — read through the public window (getter runs)
print(t.celsius)
# → getter returns self._celsius
# → prints 50
# 3 — direct back door access (no getter/setter, no validation)
print(t._celsius)
# → prints 50 directly, bypassing everything

All three see the same number. The difference is what happens in between.

Why not just use celsius everywhere and forget _celsius?
Section titled “Why not just use celsius everywhere and forget _celsius?”

Because if you tried to store to celsius inside the class itself:

@celsius.setter
def celsius(self, value):
self.celsius = value # ← WRONG

Python sees self.celsius = value and thinks — “celsius has a setter, let me call it.” Which calls the setter. Which calls self.celsius = value again. Which calls the setter again. Forever.

setter called
self.celsius = value
setter called
self.celsius = value
setter called
...
RecursionError 💥

self._celsius = value breaks the loop because _celsius has no setter — it is just a plain attribute. Python stores the value directly without triggering anything.

class Temperature:
def __init__(self, celsius):
self.celsius = celsius # ← uses the PUBLIC setter (with validation)
@property
def celsius(self):
return self._celsius # ← reads the PRIVATE storage
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError()
self._celsius = value # ← writes to PRIVATE storage directly

Reading the flow:

Temperature(100)
→ __init__ runs
self.celsius = 100 calls setter ✓
→ validation passes
self._celsius = 100 stores directly ✓
t.celsius calls getter ✓
return self._celsius reads directly ✓
→ returns 100

Inside the class, always read and write _celsius directly. Outside the class, always use celsius. The property bridges the two — it is the translation layer between the outside world and the internal storage.

When to use @property:

  • You need validation on assignment
  • You need to compute a value rather than store it
  • You need side effects when a value changes — like updating a cache or notifying something
  • You want to make an attribute read-only — define the getter but no setter

When NOT to use it:

  • If the attribute is just storing a value with no logic — use a plain attribute. Do not add @property just to follow a Java habit.
  • That is the most common mistake Python beginners make coming from other languages.
self.celsius # public — use freely from anywhere
self._celsius # "private by convention" — please don't touch this from outside
self.__celsius # name-mangled — harder to access but still not truly private

The single underscore _celsius is a gentleman’s agreement — it says this is an internal implementation detail, don’t rely on it. Nothing stops you from accessing it externally, but you are signalling to other developers that they should not.

In Python you do not need to make everything private by default like in Java. Start with public attributes. Use _name when you want to signal internal use only. Use @property when you need logic around access. That is the complete picture.

  • Java says: I do not trust you, I will enforce access rules.
  • Python says: I trust you, I will tell you my intentions through naming conventions and you will respect them.
t.fahrenheit = 32
→ fahrenheit setter runs
self.celsius = (32 - 32) * 5 / 9 = 0.0
→ celsius setter runs
self._celsius = 0.0 ← stored here
print(t.celsius)
→ celsius getter runs
return self._celsius ← reads 0.0
→ prints 0.0

_celsius is the storage. celsius is the interface. They always point to the same value — you just access it through the clean public name celsius rather than the internal _celsius.

It is completely correct — because you have no @property defined for name or salary.

The recursion problem only happens when you define a @property with the same name as the attribute you are trying to store to. Here is the key distinction:

No property, plain attribute, always correct

Section titled “No property, plain attribute, always correct”
class Employee:
def __init__(self, name, salary):
self.name = name # plain attribute, stored directly
self.salary = salary # plain attribute, stored directly

self.name = name just stores the value directly in memory. No getter, no setter, no interception. Simple and correct.

class Employee:
def __init__(self, name, salary):
self.name = name # plain attribute, fine
self.salary = salary # ← PROBLEM if you define @property salary below
@property
def salary(self):
return self._salary # reads from _salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("salary cannot be negative")
self._salary = value # stores to _salary

Now self.salary = salary in __init__ triggers the setter — which is actually what you want here. And the setter stores to self._salary — no recursion.

A summary of the @property patterns covered in this chapter, when to use each and what it gives you.

ConceptSyntaxPurpose
Getter@propertyRead a value — with optional logic
Setter@name.setterWrite a value — with validation
Read-onlygetter only, no setterRaises AttributeError on assignment
Computedgetter with no stored valueCalculated on the fly, never stored
Storageself._nameInternal attribute, bypasses the property
Public interfaceself.nameGoes through getter/setter
Name-mangledself.__nameHarder to access externally
WhenUse
No logic neededPlain attribute — no @property
Validation on write@property + setter
Computed value@property getter only
Read-only attribute@property getter, no setter
Side effects on change@property + setter

Create a Temperature class that:

  • Stores temperature internally in Celsius
  • Exposes a celsius property with validation (cannot be below -273.15)
  • Exposes a fahrenheit property that converts automatically
  • Exposes a kelvin property that converts automatically
  • All three properties have setters that update the internal value correctly
Expected output
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
print(t.kelvin) # 373.15
t.fahrenheit = 32
print(t.celsius) # 0.0
t.celsius = -300 # ValueError: Below absolute zero
Solution
exercise_01.py
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius # store only celsius as the source of truth
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value) -> None:
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@property # getter
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
@fahrenheit.setter # setter
def fahrenheit(self, value) -> None:
self._celsius = (value - 32) * 5 / 9 # convert and store as celsius
@property
def kelvin(self) -> float:
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value) -> None:
self._celsius = value - 273.15 # convert and store as celsius
def __repr__(self) -> str:
return f"Temperature(celsius={self._celsius})"
test_exercise_01.py
import pytest
from exercises.ch01_oop.properties.exercise_01 import Temperature
def test_temperature(capsys):
t = Temperature(100)
print(t.celsius)
captured = capsys.readouterr()
assert captured.out == "100\n"
print(t.fahrenheit)
captured = capsys.readouterr()
assert captured.out == "212.0\n"
print(t.kelvin)
captured = capsys.readouterr()
assert captured.out == "373.15\n"
t.fahrenheit = 32
print(t.celsius)
captured = capsys.readouterr()
assert captured.out == "0.0\n"
with pytest.raises(ValueError, match="Temperature below absolute zero"):
t.celsius = -300