Skip to content

Someone set the price to -5, and my report broke

  • Python · Core
  • 15 min read, about 1 h with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

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 = -5

Nothing stopped it. You want every price checked when it’s set. But you don’t want to change twenty files that write product.price.

  • Input: a product name and a price in cents.
  • Output: a Product whose price can never be negative. Setting one, in __init__ or later, raises ValueError("price can't be negative: -5").
  • Rules: code that reads or sets p.price keeps 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.

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.

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 = value

The 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:

KindFirst parameterCalled asUse it for
A normal methodself, the objectp.discount(10)Work on one object
@classmethodcls, the classProduct.from_line("Pen, 1.50")Other ways to make an object
@staticmethodnoneProduct.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.

Every rung reads and sets a price in O(1). What changes is whether a bad price gets in, and whether callers must change.

Okay

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.

Better

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 = value

Where 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.

Best

@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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Product("pen", 150).price → 150
  2. Not run yet: p.price = 99; p.price → 99
  3. Not run yet: p.price = -5 → raises ValueError
  4. Not run yet: Product("pen", -1) → raises ValueError
  5. Not run yet: Product.from_line("Pen, 1.50") → ['Pen', 150]
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayBad price gets inCallers changeUse it when
Okay: plain attributeYesNoAny value is fine
Better: get_ and set_Through __init__ and typosEvery oneNever in Python
Best: @propertyNoNoThe value needs a check or is worked out
  • A property with no setter is read-only: p.price = 5 raises AttributeError. Use that for values worked out from others.
  • The getter and setter need the same name: @price.setter must decorate a method called price.
  • Don’t make everything a property: Python has no need for Java-style getters. Add a property only when you need the code.
  • @classmethod gets cls, not self: write cls(…), not Product(…), so subclasses work.
  • functools.cached_property works out a value once, on first read, and stores it. Use it for slow values that never change.

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 price and turn it into a property. The getter returns self._price; the setter validates and raises ValueError for a negative value. __init__ assigns through self.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 @classmethod constructor that calls cls(...), so subclasses get their own type.”

Likely follow-ups

  • @classmethod or @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 _price private? Only by agreement. __price (two underscores) changes the name to _Product__price, which avoids clashes in subclasses, but still isn’t private.
  • How does @property work? It’s a descriptor. The descriptors lesson shows how.

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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Temperature(20).fahrenheit → 68
  2. Not run yet: t.celsius = 30; t.fahrenheit → 86
  3. Not run yet: t.fahrenheit = 212; t.celsius → 100
  4. 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
Okay

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.

Better

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 + 32

Where it breaks: t.fahrenheit = 212 raises AttributeError, because the property has no setter.

Best

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 / 9

When 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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Till([100, 250]).total → 350
  2. Not run yet: t.add(50); t.total → 400
  3. 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
Okay

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.

Better

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 read

Where it breaks: add() doesn’t touch total, so after one new sale it’s wrong.

Best

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._total

When 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Date.from_iso("2026-11-08") → [2026, 11, 8]
  2. Not run yet: class Holiday(Date); Holiday.from_iso("2026-12-25") → 'Holiday'
Try your own input
Show the Okay, Better and Best answers
Okay

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.

Better

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.

Best

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 on

When Best isn’t best: for real dates, use datetime.date.fromisoformat(). It checks the month and day too.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Spot the bugSetting p.price = 5 raises RecursionError. Why?
    class Product:
        @property
        def price(self):
            return self._price
    
        @price.setter
        def price(self, value):
            self.price = value
  2. Predict the outputWhat does this print?
    class Shape:
        @classmethod
        def make(cls):
            return cls()
    class Circle(Shape):
        pass
    print(type(Circle.make()).__name__)
  3. Multiple choiceTwenty files use p.price. You need to check every new price. What changes the least code?
  4. Predict the outputWhat happens?
    class T:
        def __init__(self):
            self._c = 20
        @property
        def c(self):
            return self._c
    t = T()
    t.c = 30

Add these to your review deck. Each card comes back a little later every time you get it right.

  • @property runs code on read; @name.setter runs code on write. Callers still write obj.name.
  • Store the value in self._name, and set self.name in __init__ so the check runs there too.
  • A property without a setter is read-only.
  • @classmethod gets cls: use it for other ways to make an object, and call cls(…).
  • @staticmethod gets nothing: a helper kept with the class.

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