LESSON 60 · DEEPER PYTHON30 min

Use Properties to Keep Python Objects Valid

Protect an object's state with a property setter and compute a read-only derived value.

Before you start: Classes and Objects · Conditions · Exceptions

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Use Properties to Keep Python Objects Valid

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    An object is useful only while its fields make sense together. Suppose a temperature is created at twenty degrees Celsius. A plain public attribute lets another part of the program assign negative three hundred without warning, even though that is below absolute zero. Every later calculation now starts from an impossible state. Python properties give us ordinary attribute syntax with a validation step behind it. In this lesson we will run the broken class first, then make a common setter mistake that causes recursion, read that error, and finally build a safe Temperature class. The constructor and every later update will obey the same rule.

  2. 00:42

    We will also calculate Fahrenheit from Celsius instead of keeping a second value that could become stale. The matching PythonLessonLab page has a browser console, worked examples, a checked task, and a quiz. Before copying a fix, decide where the validation must run so an invalid object cannot exist for even one moment. Consider also what should happen when a user enters text, infinity, or a boolean instead of a temperature. Rejecting an impossible number is only one part of a dependable contract. We will make those choices explicit and test that a failed write does not damage an earlier good reading.

  3. 01:22

    The constructor accepted twenty, which was fine. The next assignment bypassed any rule because there is no rule attached to this attribute. The value negative three hundred is now stored, and the second printed line confirms the invariant has been broken. We could ask every caller to remember a check, but one forgotten check would recreate the bug. The class should own its own validity. A property named celsius lets callers continue to write `t.celsius = value`; Python invokes its setter, which can reject the write. The backing storage will have a different name, `_celsius`, so the setter can store the accepted value without calling itself again.

  4. 02:04

    Importantly, the constructor should assign through `self.celsius` too. If it wrote straight to `_celsius`, an invalid initial value would slip past the guard. We need to check numeric type, finite value, and the lower bound before changing stored state. A failed update should raise and leave the last good temperature untouched. This setter looks almost right at a glance, but its assignment uses the public property name. Assigning to that name invokes the setter again. The second call makes the same assignment, then a third, and the chain continues until Python stops it with RecursionError. Increasing the recursion limit would only let the mistake repeat longer.

  5. 02:46

    The getter may read `_celsius`, and the setter must write `_celsius` after validation. The underscore is a naming convention for implementation detail, not a secret or a security barrier. Notice another subtle type issue: in Python, `isinstance(True, int)` is True. If a temperature API should accept real numeric measurements but not booleans, use an exact type check for int or float. Also reject NaN and infinity using `math.isfinite`; comparisons with NaN do not behave like normal ordered numbers. This diagnosis gives us a short design rule: validate the public write, then commit it once to a separate backing attribute.

  6. 03:29

    Construction writes through the setter, so zero is checked before it is stored. The getter simply returns the accepted backing value. The setter rejects anything outside our contract, and only the final line changes `_celsius`. The Fahrenheit property calculates from the current Celsius reading each time. There is no separate `_fahrenheit` field to synchronize, and there is no Fahrenheit setter, so the derived value is read-only in this design. Updating Celsius from zero to one hundred changes Fahrenheit from thirty-two to two hundred twelve automatically. The rule here is modest but powerful: store the smallest authoritative state and derive what can be computed from it.

  7. 04:13

    A property should still feel like an attribute, so avoid putting a slow network call inside the getter. If calculations are expensive or have side effects, an explicit method makes that behavior clearer. This class is also deliberately small; a real measurement application may need a policy for units, precision, and decimal arithmetic. Here we can test the stated invariant directly. The first assertion checks the exact allowed lower boundary. We round the calculated Fahrenheit value only for comparison because binary floating-point arithmetic can carry a tiny representation difference. Next we set a valid value, then try three invalid updates: below absolute zero, boolean True, and NaN.

  8. 05:00

    Every attempt must raise ValueError. At the end, Celsius is still twenty, proving validation happened before mutation. This test does not claim to cover every possible input, but it exercises the most important contract decisions and catches the setter errors we saw earlier. In the companion lesson, try the solved examples, then complete the celsius setter and Fahrenheit getter yourself. If a hidden check fails, ask whether construction bypasses the setter, whether an invalid assignment changes `_celsius`, or whether Fahrenheit was stored instead of calculated. The general pattern applies to ranges, dates, and other object invariants: put a narrow guard at the write boundary, preserve the last valid state on failure, and keep derived values derived.

  9. 05:50

    Try one more experiment after the quiz: assign Fahrenheit directly. With no setter, Python raises AttributeError, reminding us that this value is deliberately computed from the authoritative Celsius field.

Understand the concept

An attribute may begin as a simple value and later need a rule. A property keeps familiar attribute access while running a getter or setter method behind it. It is useful when the object must preserve an invariant: a Temperature cannot be colder than absolute zero, and a Fahrenheit value should always agree with the stored Celsius value. The public name is celsius; the stored implementation detail is _celsius.

The constructor should assign through self.celsius, not directly to self._celsius. That routes initial values through the same validation as later writes. @property declares the getter, and @celsius.setter declares the matching write path. Validate before changing the backing attribute so a failed assignment leaves the previous good value intact. In Python, bool is a subclass of int, so a temperature API that accepts only ordinary int or float values must reject bool explicitly.

A computed @property for fahrenheit can return self.celsius * 9 / 5 + 32. It needs no setter and no second stored number. This avoids two values drifting apart after a Celsius update. Properties are not a security boundary: callers can still reach _celsius by convention. Their purpose here is a clear, reliable object interface. Avoid using a property for surprising I/O or expensive work, because normal attribute access suggests a quick operation.

The exercise starts with a class skeleton. Make valid numeric updates work, reject values below -273.15 and non-finite numbers, and keep Fahrenheit synchronized. Very large Python integers can overflow when converted for math.isfinite or the Fahrenheit calculation, so reject those with ValueError too. The checker tests the constructor, a later assignment, a failed assignment, and the read-only derived property. Run the prepared example first, then use the hint if your setter accidentally calls itself.

  • @property keeps attribute syntax while running a getter
  • A setter can enforce an invariant on construction and update
  • Validate before mutating stored state
  • A derived read-only property avoids duplicate state

See it step by step

Read the code, predict the output, then compare it with the result.

01. Validate both construction and a later write

PYTHON
class Score:
    def __init__(self, points):
        self.points = points

    @property
    def points(self):
        return self._points

    @points.setter
    def points(self, value):
        if not 0 <= value <= 100:
            raise ValueError("points must be 0..100")
        self._points = value

score = Score(40)
score.points = 75
print(score.points)
EXPECTED OUTPUT
75

Both writes use the setter, so construction cannot bypass the range rule.

02. Keep a valid value after a rejected update

PYTHON
class Level:
    def __init__(self, value):
        self.value = value

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, number):
        if number < 0:
            raise ValueError("negative level")
        self._value = number

level = Level(3)
try:
    level.value = -1
except ValueError as error:
    print(error)
print(level.value)
EXPECTED OUTPUT
negative level
3

Validation happens before the backing attribute is changed.

03. Calculate rather than duplicate derived state

PYTHON
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height

shape = Rectangle(2, 3)
print(shape.area)
shape.width = 4
print(shape.area)
EXPECTED OUTPUT
6
12

area is calculated on access, so it reflects the updated width automatically.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Find the invalid state an ordinary attribute permits

An object can begin valid and become invalid later if a public attribute has no guard. A temperature below absolute zero is a useful concrete invariant: every instance should satisfy it after construction and after every update. Run the small example and observe that a plain attribute accepts a value the application cannot meaningfully use.

A getter alone does not solve the problem if callers can still write directly to the public field. Give callers a celsius property and keep the actual value in _celsius. Python's leading underscore is a convention that marks implementation detail; it does not hide the value. The setter is the single place to enforce the rule.

PYTHON
class PlainTemperature:
    def __init__(self, celsius):
        self.celsius = celsius

reading = PlainTemperature(20)
reading.celsius = -300
print(reading.celsius)
print(reading.celsius < -273.15)
EXPECTED OUTPUT
-300
True
Follow the reasoning
  1. Construct a reasonable 20-degree reading.
  2. Assign an impossible value through the unguarded attribute and inspect the broken invariant.
02 / 03

Validate before the backing attribute changes

The constructor calls the property setter by writing self.celsius. The setter checks the exact numeric types so True, despite being an int subclass, is rejected. math.isfinite rejects NaN and infinity; an oversized int can raise OverflowError during float conversion, so handle it as invalid input. Check that the derived Fahrenheit value stays finite too. Only after every test passes does it assign _celsius, preserving a previous valid reading if an update raises ValueError.

Do not write self.celsius inside the celsius setter. That would call the setter again and recurse until Python raises RecursionError. The backing name is deliberately different. A property is a small interface around state, so give it a predictable error and keep unrelated side effects out of the getter or setter.

PYTHON
import math

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if type(value) not in (int, float):
            raise ValueError("invalid Celsius temperature")
        try:
            finite = math.isfinite(value) and math.isfinite(value * 9 / 5 + 32)
        except OverflowError:
            finite = False
        if not finite or value < -273.15:
            raise ValueError("invalid Celsius temperature")
        self._celsius = value

t = Temperature(20)
try:
    t.celsius = -300
except ValueError as error:
    print(error)
print(t.celsius)
EXPECTED OUTPUT
invalid Celsius temperature
20
Follow the reasoning
  1. Trace construction through the setter before _celsius exists.
  2. Watch the failed update raise before the previously stored 20 can change.
03 / 03

Compute derived values and test the boundary

Fahrenheit is derived from Celsius, so storing a second field creates a synchronization problem. A read-only property calculates the value on every access and reflects a later Celsius assignment automatically. Assigning to fahrenheit raises AttributeError because no setter exists. That is an intentional part of this small class interface, not a missing feature.

Test the exact physical boundary, a normal update, and invalid non-finite or oversized values. Round only for display or comparison where floating-point arithmetic introduces a tiny representation difference. The state itself remains the supplied Celsius number. For a real scientific system, units and measurement precision deserve their own design; the exercise focuses on enforcing one clear invariant.

PYTHON
import math

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if type(value) not in (int, float):
            raise ValueError("invalid Celsius temperature")
        try:
            finite = math.isfinite(value) and math.isfinite(value * 9 / 5 + 32)
        except OverflowError:
            finite = False
        if not finite or value < -273.15:
            raise ValueError("invalid Celsius temperature")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

t = Temperature(-273.15)
print(round(t.fahrenheit, 2))
t.celsius = 100
print(t.fahrenheit)
EXPECTED OUTPUT
-459.67
212.0
Follow the reasoning
  1. Calculate the lower boundary from Celsius without storing a second value.
  2. Update Celsius and observe that Fahrenheit follows automatically.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete the celsius setter and fahrenheit getter. Accept only values whose exact type is int or float, that are finite, at least -273.15, and yield a finite Fahrenheit value. Raise ValueError for anything else, including integers too large for floating-point conversion, before changing _celsius. Fahrenheit is celsius * 9 / 5 + 32. The prepared program should print 32.0 and 212.0.
Need a hint?

Reject types outside (int, float) first. In a try block, check math.isfinite(value) and math.isfinite(value * 9 / 5 + 32); treat OverflowError from a huge int as invalid. Also reject value < -273.15. Raise ValueError before assigning self._celsius. The getter returns self.celsius * 9 / 5 + 32. Do not assign to self.celsius inside its own setter.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Why should __init__ assign through self.celsius?

    02.What happens to a valid backing value if a setter validates before assignment and then raises?

    03.Why compute fahrenheit rather than store it separately?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Calling the setter recursively

    COMMON MISTAKE
    @celsius.setter
    def celsius(self, value):
        self.celsius = value
    FIX
    @celsius.setter
    def celsius(self, value):
        self._celsius = value

    What happens: Assigning the property inside its own setter repeatedly calls that setter.
    Store into a distinct backing attribute after validation.

    Bypassing validation in the constructor

    COMMON MISTAKE
    def __init__(self, celsius):
        self._celsius = celsius
    FIX
    def __init__(self, celsius):
        self.celsius = celsius

    What happens: An invalid object can be created even if later writes are checked.
    Route initial assignment through the public property.

    Mutating before checking

    COMMON MISTAKE
    self._celsius = value
    if value < -273.15: raise ValueError('too cold')
    FIX
    if value < -273.15: raise ValueError('too cold')
    self._celsius = value

    What happens: The object holds a bad value after the exception.
    Complete validation before committing state.

    Browse this lesson’s problem fixes

    Sources and further reading