Learning Goals
3 minBy the end of this lesson you can:
- Decorate a method with
@propertyto make it accessible as an attribute. - Add a setter with
@attr.setterfor controlled write access with validation. - Decide when computed attributes beat plain attributes (and vice versa).
- Convert old
get_x()/set_x()code to the property style.
Warm-Up · A Getter Method vs a Property
5 minCompare the two styles:
# Old style — explicit getter method class Hero: def __init__(self, hp): self._hp = hp def health(self): return self._hp h = Hero(80) print(h.health()) # method call — note the ()
# New style — property class Hero: def __init__(self, hp): self._hp = hp @property def health(self): return self._hp h = Hero(80) print(h.health) # attribute access — no ()!
Same logic, different feel. The property version reads like accessing data — even though it's running a method behind the scenes. That tiny syntax difference is huge for readability.
If something looks like data to the user, write it as a property. If it's clearly an action, keep it as a method. hero.health reads as data; hero.attack(monster) reads as action.
New Concept · Property & Setter
14 minRead-only property
class Rectangle: def __init__(self, w, h): self._w = w self._h = h @property def area(self): return self._w * self._h r = Rectangle(3, 4) print(r.area) # → 12 -- no parens r.area = 50 # AttributeError: property 'area' has no setter
Without a setter, the property is read-only. Trying to assign raises AttributeError.
Always-fresh computed values
The crucial benefit over caching in __init__: properties recompute every time. If you change _w, the next read of area reflects it.
r = Rectangle(3, 4) print(r.area) # 12 r._w = 10 print(r.area) # 40 -- recomputed
Compare to PY-L3-04's "self.area = w * h in __init__" — that's a cached value, stale the moment _w changes. Properties are alive.
Adding a setter — controlled writes
class Temperature: def __init__(self): self._celsius = 22 @property def celsius(self): return self._celsius @celsius.setter def celsius(self, value): if not (-50 <= value <= 60): raise ValueError(f"Out of range: {value}") self._celsius = value t = Temperature() print(t.celsius) # 22 (getter) t.celsius = 28 # setter — looks like assignment! print(t.celsius) # 28 t.celsius = 999 # raises ValueError
Two new things: (1) the setter is decorated @celsius.setter — the name matters; it must match the property. (2) the setter takes the new value as its parameter and stores it on a private attribute. Validation lives in the setter — invalid assignments raise immediately.
Multiple properties depending on the same storage
Once you have one source of truth (_celsius), other properties can derive from it on demand:
class Temperature: def __init__(self): self._celsius = 22 @property def celsius(self): return self._celsius @celsius.setter def celsius(self, value): if not (-50 <= value <= 60): raise ValueError(value) self._celsius = value @property def fahrenheit(self): return self._celsius * 9 / 5 + 32 @fahrenheit.setter def fahrenheit(self, value): self.celsius = (value - 32) * 5 / 9 # delegate validation
Set in Celsius or Fahrenheit; either way the data is stored consistently in _celsius. The fahrenheit setter routes through celsius to reuse its validation.
When NOT to use @property
For a simple value-store (a plain attribute), don't wrap it in a property unless you need validation or computation. Premature wrapping = ceremony for no reason.
class Point: def __init__(self, x, y): self.x = x # plain attribute. fine. self.y = y # no @property needed.
Use properties when you have: (1) a value derived from others, (2) something that needs validation on write, or (3) an attribute you want to be read-only.
Worked Example · Hero with Properties
12 minRefactor Hero. Save as hero_props.py:
# hero_props.py — Hero using @property class Hero: def __init__(self, name, hp=100, attack_power=12): self.name = name self._hp = hp self._max_hp = hp self.attack_power = attack_power self.xp = 0 # --- properties --- @property def health(self): """Current HP — derived storage.""" return self._hp @health.setter def health(self, value): if value < 0: self._hp = 0 elif value > self._max_hp: self._hp = self._max_hp else: self._hp = value @property def max_hp(self): return self._max_hp @property def alive(self): return self._hp > 0 @property def health_pct(self): return self._hp / self._max_hp * 100 @property def level(self): """Derived — no storage at all.""" return self.xp // 100 + 1 def __repr__(self): return (f"Hero(name={self.name!r}, hp={self._hp}, " f"max_hp={self._max_hp}, xp={self.xp})") h = Hero("Aisyah", hp=100) print(h.health) # 100 -- looks like an attribute print(h.alive) # True print(f"{h.health_pct:.0f}%") # 100% print(f"Level {h.level}") # Level 1 # Setter — clamps to valid range h.health = 150 # too high — clamped print(h.health) # 100 h.health = -10 # too low — clamped to 0 print(h.health) # 0 print(h.alive) # False # Level changes automatically h.xp = 250 print(h.level) # 3 (= 250 // 100 + 1)
Read the diff
Five properties. health has a setter that clamps to [0, max_hp]. max_hp is read-only (no setter). alive is pure computed, takes no storage. health_pct derives from two private fields. level derives from xp — no level attribute exists; it's computed each read. From the user's side, everything looks like attribute access. The implementation does the right thing under the hood.
Try It Yourself
13 minDefine Circle(radius) with a radius attribute and a property area that returns π × r². Test that changing radius updates area.
Hint
import math class Circle: def __init__(self, radius): self.radius = radius @property def area(self): return math.pi * self.radius ** 2 c = Circle(3) print(c.area) # 28.27... c.radius = 5 print(c.area) # 78.53... -- live
Define Person with a private _age and a property age with a setter that rejects negative or >150 ages with a ValueError.
Hint
class Person: def __init__(self, name, age): self.name = name self.age = age # uses the setter — validates on construct too! @property def age(self): return self._age @age.setter def age(self, value): if not (0 <= value <= 150): raise ValueError(f"Age out of range: {value}") self._age = value p = Person("Aisyah", 12) p.age = 13 # OK try: p.age = -1 # rejected except ValueError as e: print(e)
Notice the constructor uses self.age = age — that invokes the setter, validating at construction.
Define BMI(weight_kg, height_m). Make weight_kg and height_m regular attributes. Add a property value that returns BMI = weight_kg / height_m². Add a property category that returns "underweight" (<18.5), "normal" (18.5-25), "overweight" (25-30), or "obese" (>30).
Hint
class BMI: def __init__(self, weight_kg, height_m): self.weight_kg = weight_kg self.height_m = height_m @property def value(self): return self.weight_kg / (self.height_m ** 2) @property def category(self): v = self.value if v < 18.5: return "underweight" if v < 25: return "normal" if v < 30: return "overweight" return "obese" b = BMI(70, 1.75) print(f"BMI: {b.value:.1f} ({b.category})") # 22.9 (normal)
Both properties are pure computed — no storage. Changing weight_kg updates both value and category.
Mini-Challenge · The Validated Bank Account
8 minBuild bank_props.py. A BankAccount class with:
- A private
_balance(starts 0). - A
balanceproperty with a setter that rejects negative values. - A
statusproperty — returns"OK"if balance ≥ 0,"OVERDRAWN"otherwise (impossible if setter enforces non-negative — fine, defensive code). - Methods
deposit(amount)andwithdraw(amount)that update the balance via the setter.
Show one possible solution
# bank_props.py class BankAccount: def __init__(self, owner): self.owner = owner self._balance = 0 @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError(f"Balance can't be negative: {value}") self._balance = round(float(value), 2) @property def status(self): return "OK" if self._balance >= 0 else "OVERDRAWN" def deposit(self, amount): self.balance = self.balance + amount def withdraw(self, amount): new_balance = self.balance - amount if new_balance < 0: raise ValueError(f"Insufficient funds. Have {self.balance}, want {amount}.") self.balance = new_balance def __repr__(self): return f"BankAccount({self.owner!r}, balance={self._balance})" a = BankAccount("Aisyah") a.deposit(500) a.withdraw(150) print(a) # → BankAccount('Aisyah', balance=350.0) print(a.balance) # 350.0 print(a.status) # OK try: a.withdraw(1000) except ValueError as e: print(e)
Non-negotiables: balance is a property with a validating setter, status is a derived property, deposit/withdraw use the property (which means they get validated for free). The class can't end up in an invalid state.
Recap
3 min@property turns a method into an attribute-shaped read. @attr.setter adds a controlled write with validation. Properties are always-fresh — they compute on each read, never go stale. Use them for derived values (area, level, health_pct), validated writes (age, balance, temperature), and read-only fields. Don't wrap plain data unless you need one of those.
Vocabulary Card
- @property
- Decorator that turns a method into an attribute-shaped read.
- @attr.setter
- Decorator for the matching write side. Allows validation.
- computed attribute
- An attribute whose value is calculated each time it's read.
- read-only property
- A property with no setter — assignment raises AttributeError.
Homework
4 minRe-do the Pet class from PY-L3-17. Replace price() with a property price. Add a setter that rejects negative values. Make kind a read-only property that returns type(self).__name__. Test all three.
Sample · Pet with properties
class Pet: def __init__(self, name, age, price): self.name = name self.age = age self.price = price # invokes setter — validates @property def price(self): return self._price @price.setter def price(self, value): if value < 0: raise ValueError(f"Price can't be negative: {value}") self._price = round(float(value), 2) @property def kind(self): return type(self).__name__ def discount(self, percent): self.price = self.price * (1 - percent / 100) class Dog(Pet): pass class Cat(Pet): pass d = Dog("Fido", 3, 450) print(d.price) # 450.0 print(d.kind) # 'Dog' d.discount(10) print(d.price) # 405.0 try: d.price = -50 except ValueError as e: print(e)
Non-negotiables: price as a validated property, kind as a read-only computed property, discount() going through the setter. Now pet.price reads cleanly and validation is automatic.