Properties
@property
Section titled “@property”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 getterprint(c.area) # 78.53 ← computed on the flyc.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._celsiust._celsius → direct access to the private attributet.celsius— the public door — goes through the getter, which could have logic in itt._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 everythingAll 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.setterdef celsius(self, value): self.celsius = value # ← WRONGPython 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.
The naming convention visualised
Section titled “The naming convention visualised”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 directlyReading 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 100The rule in one sentence
Section titled “The rule in one sentence”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.
Getter and Setter, the Python way
Section titled “Getter and Setter, the Python way”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
@propertyjust to follow a Java habit. - That is the most common mistake Python beginners make coming from other languages.
The practical takeaway
Section titled “The practical takeaway”self.celsius # public — use freely from anywhereself._celsius # "private by convention" — please don't touch this from outsideself.__celsius # name-mangled — harder to access but still not truly privateThe 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
@propertywhen 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.
The full flow for t.fahrenheit = 32
Section titled “The full flow for t.fahrenheit = 32”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.
Plain attribute vs. @property
Section titled “Plain attribute vs. @property”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 directlyself.name = name just stores the value directly in memory. No getter, no setter, no interception. Simple and correct.
With property needs the underscore
Section titled “With property needs the underscore”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 _salaryNow self.salary = salary in __init__ triggers the setter — which is actually what you want here. And the setter
stores to self._salary — no recursion.
Quick reference
Section titled “Quick reference”A summary of the @property patterns covered in this chapter, when to use each and what it gives you.
| Concept | Syntax | Purpose |
|---|---|---|
| Getter | @property | Read a value — with optional logic |
| Setter | @name.setter | Write a value — with validation |
| Read-only | getter only, no setter | Raises AttributeError on assignment |
| Computed | getter with no stored value | Calculated on the fly, never stored |
| Storage | self._name | Internal attribute, bypasses the property |
| Public interface | self.name | Goes through getter/setter |
| Name-mangled | self.__name | Harder to access externally |
| When | Use |
|---|---|
| No logic needed | Plain 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 |
Exercises
Section titled “Exercises”Exercise 1 — Properties
Section titled “Exercise 1 — Properties”Create a Temperature class that:
- Stores temperature internally in Celsius
- Exposes a
celsiusproperty with validation (cannot be below -273.15) - Exposes a
fahrenheitproperty that converts automatically - Exposes a
kelvinproperty that converts automatically - All three properties have setters that update the internal value correctly
t = Temperature(100)print(t.celsius) # 100print(t.fahrenheit) # 212.0print(t.kelvin) # 373.15
t.fahrenheit = 32print(t.celsius) # 0.0
t.celsius = -300 # ValueError: Below absolute zeroSolution
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})"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