Someone set the price to -5, and my report broke
See the problem
Section titled “See the problem”Your shop keeps products as objects. Twenty files read and set product.price, in cents. One day the sales report shows a total below zero. Somewhere, a script ran:
pen.price = -5Nothing stopped it. You want every price checked when it’s set. But you don’t want to change twenty files that write product.price.
Define the task
Section titled “Define the task”- Input: a product name and a price in cents.
- Output: a
Productwhose price can never be negative. Setting one, in__init__or later, raisesValueError("price can't be negative: -5"). - Rules: code that reads or sets
p.pricekeeps working, unchanged.Product.from_line("Pen, 1.50")makes a product from a line of a price list. - The hard part: adding a check without changing how anyone uses the attribute.
Example: p = Product("pen", 150), then p.price = -5 raises ValueError, and p.price is still 150.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
get_price() and set_price() can check, but every caller would have to change.
Hint 2 of 3
@property makes a method that runs when you read p.price, with no brackets.
Hint 3 of 3
@price.setter runs when someone writes p.price = …. Set self.price in __init__ too, so the check runs there.
Understand the idea
Section titled “Understand the idea”A property looks like an attribute from outside, but runs your code inside:
class Product: @property def price(self): # runs for: p.price return self._price
@price.setter def price(self, value): # runs for: p.price = value if value < 0: raise ValueError(...) self._price = valueThe value lives in self._price. The leading _ means “inside use only”. Python doesn’t enforce it, but every Python developer reads it that way.
Three kinds of method:
| Kind | First parameter | Called as | Use it for |
|---|---|---|---|
| A normal method | self, the object | p.discount(10) | Work on one object |
@classmethod | cls, the class | Product.from_line("Pen, 1.50") | Other ways to make an object |
@staticmethod | none | Product.cents("1.50") | A helper that belongs with the class |
A class method gets the class it was called on. So Sale.from_line(…) makes a Sale, even though the method lives in Product.
Climb the ladder
Section titled “Climb the ladder”Every rung reads and sets a price in O(1). What changes is whether a bad price gets in, and whether callers must change.
A plain attribute
The idea: Store the price as self.price.
Short and clear. Most attributes should start like this.
class Product: def __init__(self, name: str, price: int) -> None: self.name = name self.price = price # in cents; anyone can set it to anything- Time
O(1)- Space
O(1)- Why
- One attribute.
Where it breaks: nothing checks the value, so pen.price = -5 is stored, and the report breaks later, far from the cause.
get and set methods
The idea: Hide the value in _price and make callers use get_price() and set_price(), which checks.
The waste in Okay: it has no place to put a check.
set_price(-5) raises ValueError.
Highlighted lines are new in this rung.
class Product: def __init__(self, name: str, price: int) -> None: self.name = name self._price = price # in cents
def get_price(self) -> int: return self._price
def set_price(self, value: int) -> None: if value < 0: raise ValueError(f"price can't be negative: {value}") self._price = value- Time
O(1)- Space
O(1)- Why
- One check per set.
What changed from the previous rung
class Product: def __init__(self, name: str, price: int) -> None: self.name = name self.price = price # in cents; anyone can set it to anything self._price = price # in cents
def get_price(self) -> int: return self._price
def set_price(self, value: int) -> None: if value < 0: raise ValueError(f"price can't be negative: {value}") self._price = valueWhere it breaks: the twenty files that use p.price now fail with AttributeError. Worse, p.price = -5 quietly makes a new attribute that nothing reads. And __init__ sets _price directly, so Product("pen", -1) is never checked.
@property, plus a class method
The idea: Make price a property whose setter checks; set self.price in __init__; add from_line as a @classmethod.
The waste in Better: it makes every caller change to protect one attribute.
Callers keep writing p.price. __init__ writes self.price = price, which goes through the setter, so new products are checked too. from_line uses cls(…), so a subclass gets objects of its own type. cents() needs neither the object nor the class, so it’s a @staticmethod. round() matters there: 0.29 * 100 is 28.999999999999996.
Highlighted lines are new in this rung.
class Product: def __init__(self, name: str, price: int) -> None: self.name = name self.price = price # runs the setter below, so it's checked here too
@property def price(self) -> int: return self._price
@price.setter def price(self, value: int) -> None: if value < 0: raise ValueError(f"price can't be negative: {value}") self._price = value
@classmethod def from_line(cls, line: str) -> "Product": name, price = line.split(",") return cls(name.strip(), cls.cents(price))
@staticmethod def cents(text: str) -> int: return round(float(text) * 100)- Time
O(1)- Space
O(1)- Why
- One check per set.
What changed from the previous rung
class Product: def __init__(self, name: str, price: int) -> None: self.name = name self._price = price # in cents self.price = price # runs the setter below, so it's checked here too
def get_price(self) -> int: @property def price(self) -> int: return self._price
def set_price(self, value: int) -> None: @price.setter def price(self, value: int) -> None: if value < 0: raise ValueError(f"price can't be negative: {value}") self._price = value
@classmethod def from_line(cls, line: str) -> "Product": name, price = line.split(",") return cls(name.strip(), cls.cents(price))
@staticmethod def cents(text: str) -> int: return round(float(text) * 100)When Best isn’t best: a property should be quick and have no surprises. If reading it does slow work, like a database query, make it a method, p.load_price(), so callers see the cost.
Write it yourself
Section titled “Write it yourself”Make Product check its price on every set, keep p.price working, and add from_line. The tests make a product, then read, set or build one.
A price that can’t go below zero
Ctrl + Enter runs- Not run yet:
Product("pen", 150).price→150 - Not run yet:
p.price = 99; p.price→99 - Not run yet:
p.price = -5→ raisesValueError - Not run yet:
Product("pen", -1)→ raisesValueError - Not run yet:
Product.from_line("Pen, 1.50")→['Pen', 150] - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Bad price gets in | Callers change | Use it when |
|---|---|---|---|
| Okay: plain attribute | Yes | No | Any value is fine |
Better: get_ and set_ | Through __init__ and typos | Every one | Never in Python |
Best: @property | No | No | The value needs a check or is worked out |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A property with no setter is read-only:
p.price = 5raisesAttributeError. Use that for values worked out from others. - The getter and setter need the same name:
@price.settermust decorate a method calledprice. - Don’t make everything a property: Python has no need for Java-style getters. Add a property only when you need the code.
@classmethodgetscls, notself: writecls(…), notProduct(…), so subclasses work.functools.cached_propertyworks out a value once, on first read, and stores it. Use it for slow values that never change.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Who sets this attribute, and can I change all of them?
- Is the value stored, or worked out from others?
A 60-second walkthrough
“I’d keep the public name
priceand turn it into a property. The getter returnsself._price; the setter validates and raisesValueErrorfor a negative value.__init__assigns throughself.price, so construction is validated too. Callers don’t change at all, which is the point of properties in Python: start with a plain attribute and add behaviour later. For parsing a price-list line I’d add a@classmethodconstructor that callscls(...), so subclasses get their own type.”
Likely follow-ups
@classmethodor@staticmethod? A class method gets the class, so it can make objects or read class settings. A static method gets nothing; it’s a plain function kept in the class.- Is
_priceprivate? Only by agreement.__price(two underscores) changes the name to_Product__price, which avoids clashes in subclasses, but still isn’t private. - How does
@propertywork? It’s a descriptor. The descriptors lesson shows how.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. Celsius and Fahrenheit together (easy)
Section titled “1. Celsius and Fahrenheit together (easy)”Write Temperature(celsius). t.fahrenheit must always match t.celsius, even after t.celsius changes. Setting t.fahrenheit = 212 must set t.celsius to 100.
Hint 1 of 3
Storing both numbers means they can drift apart.
Hint 2 of 3
Store one, and work out the other in a property.
Hint 3 of 3
A setter for fahrenheit can convert back and set celsius.
Celsius and Fahrenheit together
Ctrl + Enter runs- Not run yet:
Temperature(20).fahrenheit→68 - Not run yet:
t.celsius = 30; t.fahrenheit→86 - Not run yet:
t.fahrenheit = 212; t.celsius→100 - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Store both
The idea: Work out fahrenheit in __init__ and store it.
A new temperature has both right.
class Temperature: def __init__(self, celsius: float) -> None: self.celsius = celsius self.fahrenheit = celsius * 9 / 5 + 32- Time
O(1)- Space
O(1)- Why
- Two numbers.
Where it breaks: change celsius, and fahrenheit keeps the old value.
A read-only property
The idea: Store celsius only; fahrenheit is a property worked out on each read.
The waste in Okay: it stores a second copy of the same fact.
Highlighted lines are new in this rung.
class Temperature: def __init__(self, celsius: float) -> None: self.celsius = celsius
@property def fahrenheit(self) -> float: return self.celsius * 9 / 5 + 32- Time
O(1)- Space
O(1)- Why
- One sum per read.
What changed from the previous rung
class Temperature: def __init__(self, celsius: float) -> None: self.celsius = celsius self.fahrenheit = celsius * 9 / 5 + 32
@property def fahrenheit(self) -> float: return self.celsius * 9 / 5 + 32Where it breaks: t.fahrenheit = 212 raises AttributeError, because the property has no setter.
A setter that converts back
The idea: @fahrenheit.setter turns the value into Celsius and stores that.
The waste in Better: callers who think in Fahrenheit must convert by hand.
Highlighted lines are new in this rung.
class Temperature: def __init__(self, celsius: float) -> None: self.celsius = celsius
@property def fahrenheit(self) -> float: return self.celsius * 9 / 5 + 32
@fahrenheit.setter def fahrenheit(self, value: float) -> None: self.celsius = (value - 32) * 5 / 9- Time
O(1)- Space
O(1)- Why
- One sum per read or write.
What changed from the previous rung
class Temperature: def __init__(self, celsius: float) -> None: self.celsius = celsius
@property def fahrenheit(self) -> float: return self.celsius * 9 / 5 + 32
@fahrenheit.setter def fahrenheit(self, value: float) -> None: self.celsius = (value - 32) * 5 / 9When Best isn’t best: converting back and forth can add float noise, like 99.99999999999999. Round when you show it.
2. A total that’s always right and always fast (medium)
Section titled “2. A total that’s always right and always fast (medium)”Write Till(sales) with add(amount) and a total. total must include every sale, even ones added later. A dashboard reads total many times a second, so reading it must be fast with 100,000 sales.
Hint 1 of 3
A property that runs sum() is always right, but adds every sale on every read.
Hint 2 of 3
Working the total out once in __init__ is fast, but add() makes it stale.
Hint 3 of 3
Keep a running total, update it in add(), and return it from a property.
A total that’s always right and always fast
Ctrl + Enter runs- Not run yet:
Till([100, 250]).total→350 - Not run yet:
t.add(50); t.total→400 - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
sum() in a property
The idea: total adds up every sale each time it is read.
Always right.
class Till: def __init__(self, sales: list[int]) -> None: self.sales = list(sales)
def add(self, amount: int) -> None: self.sales.append(amount)
@property def total(self) -> int: return sum(self.sales) # adds every sale, on every read- Time
O(n) per read- Space
O(n)- Why
- sum() looks at all n sales on every read.
Where it breaks: 5,000 reads of 100,000 sales is 500 million additions. The dashboard grinds to a halt.
Work it out once
The idea: Store sum(sales) in __init__ as a plain attribute.
The waste in Okay: it adds up the same sales again on every read.
Highlighted lines are new in this rung.
class Till: def __init__(self, sales: list[int]) -> None: self.sales = list(sales) self.total = sum(self.sales) # worked out once
def add(self, amount: int) -> None: self.sales.append(amount)- Time
O(1) per readwas O(n) per read- Space
O(n)- Why
- One sum at the start.
What changed from the previous rung
class Till: def __init__(self, sales: list[int]) -> None: self.sales = list(sales) self.total = sum(self.sales) # worked out once
def add(self, amount: int) -> None: self.sales.append(amount)
@property def total(self) -> int: return sum(self.sales) # adds every sale, on every readWhere it breaks: add() doesn’t touch total, so after one new sale it’s wrong.
A running total
The idea: Keep _total up to date in add(), and return it from a read-only property.
The waste in Better: it lets the stored total and the sales drift apart.
The property has no setter, so nobody can set total by mistake.
Highlighted lines are new in this rung.
class Till: def __init__(self, sales: list[int]) -> None: self.sales = list(sales) self._total = sum(self.sales)
def add(self, amount: int) -> None: self.sales.append(amount) self._total += amount # keep the total up to date as sales come in
@property def total(self) -> int: return self._total- Time
O(1) per read- Space
O(n)- Why
- Each add() does one extra sum; each read does none.
What changed from the previous rung
class Till: def __init__(self, sales: list[int]) -> None: self.sales = list(sales) self.total = sum(self.sales) # worked out once self._total = sum(self.sales)
def add(self, amount: int) -> None: self.sales.append(amount) self._total += amount # keep the total up to date as sales come in
@property def total(self) -> int: return self._totalWhen Best isn’t best: if code can change sales directly, like t.sales[0] = 5, the total drifts again. Hide the list too, or go back to sum() when reads are rare.
3. Another way to make a date (hard)
Section titled “3. Another way to make a date (hard)”Write a Date(year, month, day) class with Date.from_iso("2026-11-08"), which reads the date from text. A subclass must get objects of its own type: Holiday.from_iso(…) gives a Holiday.
Hint 1 of 3
A plain function outside the class always makes a Date.
Hint 2 of 3
A @staticmethod that writes Date(...) also always makes a Date.
Hint 3 of 3
A @classmethod gets the class it was called on as cls. Call cls(...).
Another way to make a date
Ctrl + Enter runs- Not run yet:
Date.from_iso("2026-11-08")→[2026, 11, 8] - Not run yet:
class Holiday(Date); Holiday.from_iso("2026-12-25")→'Holiday'
Try your own input
Show the Okay, Better and Best answers
A plain function
The idea: date_from_iso(text) splits the text and makes a Date.
It reads the date correctly.
class Date: def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day
def date_from_iso(text: str) -> Date: year, month, day = text.split("-") return Date(int(year), int(month), int(day))- Time
O(1)- Space
O(1)- Why
- One short split.
Where it breaks: it lives outside the class, so Date.from_iso doesn’t exist, and readers must know to look for it.
A @staticmethod
The idea: Put the function in the class as a static method.
The waste in Okay: a way to make a Date that isn’t part of Date.
Highlighted lines are new in this rung.
class Date: def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day
@staticmethod def from_iso(text: str) -> "Date": year, month, day = text.split("-") return Date(int(year), int(month), int(day))- Time
O(1)- Space
O(1)- Why
- One short split.
What changed from the previous rung
class Date: def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day
def date_from_iso(text: str) -> Date: year, month, day = text.split("-") return Date(int(year), int(month), int(day)) @staticmethod def from_iso(text: str) -> "Date": year, month, day = text.split("-") return Date(int(year), int(month), int(day))Where it breaks: it writes Date(…) by name, so Holiday.from_iso(…) gives a plain Date.
A @classmethod
The idea: from_iso(cls, text) calls cls(...), the class it was called on.
The waste in Better: it names the class, when Python would hand it over.
This is how dict.fromkeys and datetime.date.fromisoformat work.
Highlighted lines are new in this rung.
class Date: def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day
@classmethod def from_iso(cls, text: str) -> "Date": year, month, day = text.split("-") return cls(int(year), int(month), int(day)) # cls is the class it was called on- Time
O(1)- Space
O(1)- Why
- One short split.
What changed from the previous rung
class Date: def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day
@staticmethod def from_iso(text: str) -> "Date": @classmethod def from_iso(cls, text: str) -> "Date": year, month, day = text.split("-") return Date(int(year), int(month), int(day)) return cls(int(year), int(month), int(day)) # cls is the class it was called onWhen Best isn’t best: for real dates, use datetime.date.fromisoformat(). It checks the month and day too.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”@propertyruns code on read;@name.setterruns code on write. Callers still writeobj.name.- Store the value in
self._name, and setself.namein__init__so the check runs there too. - A property without a setter is read-only.
@classmethodgetscls: use it for other ways to make an object, and callcls(…).@staticmethodgets nothing: a helper kept with the class.
Go deeper
Section titled “Go deeper”property,classmethodandstaticmethodin the Python docs.functools.cached_property, for slow values worked out once.- Next: Dataclasses.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started