Properties: Getters and Setters Done the Python Way

@property turns a method into an attribute. It lets you start with a plain attribute and add validation later without changing a single line of calling code.

Start with a plain attribute

class Circle:
    def __init__(self, radius):
        self.radius = radius


c = Circle(5)
print(c.radius)
c.radius = 10

In many languages this is considered unsafe, so every field gets a getter and a setter from the start. Python does not need that, because an attribute can be converted into a property later and every caller keeps working unchanged.

Adding validation without breaking callers

class Circle:
    def __init__(self, radius):
        self.radius = radius            # goes through the setter below

    @property
    def radius(self):
        """The radius, which must be positive."""
        return self._radius

    @radius.setter
    def radius(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("radius must be a number")
        if value <= 0:
            raise ValueError("radius must be positive")
        self._radius = value


c = Circle(5)
print(c.radius)          # 5 - identical syntax to before
c.radius = 10            # validated

try:
    c.radius = -3
except ValueError as error:
    print(error)

Nothing at the call site changed. That is the entire argument for not writing get_radius() and set_radius() in Python.

Note that __init__ assigns self.radius, not self._radius. This deliberately routes construction through the setter, so an object can never be created in an invalid state.

Computed properties

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

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

    @property
    def perimeter(self):
        return 2 * (self.width + self.height)

    @property
    def is_square(self):
        return self.width == self.height


r = Rectangle(4, 5)
print(r.area, r.perimeter, r.is_square)      # 20 18 False
# r.area = 100     # AttributeError: property has no setter

A read only property is the right shape for anything derived from other attributes. It cannot go stale, because it is recomputed on every access, and it cannot be set to a value that contradicts the rest of the object.

The three parts

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

    @property
    def celsius(self):
        """Temperature in degrees Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @celsius.deleter
    def celsius(self):
        print("resetting to zero")
        self._celsius = 0

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

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9      # reuse the validation


t = Temperature(25)
print(t.celsius, t.fahrenheit)       # 25 77.0

t.fahrenheit = 212
print(t.celsius)                     # 100.0 - kept consistent automatically

del t.celsius
print(t.celsius)                     # 0

Two views of one underlying value. There is no way for the Celsius and Fahrenheit readings to disagree, because only one of them is stored.

Lazy and cached properties

class Report:
    def __init__(self, rows):
        self.rows = rows
        self._summary = None

    @property
    def summary(self):
        if self._summary is None:              # compute once, on first access
            print("computing...")
            self._summary = {
                "count": len(self.rows),
                "total": sum(self.rows),
                "mean": sum(self.rows) / len(self.rows),
            }
        return self._summary


r = Report([10, 20, 30])
print(r.summary)      # computing... then the dictionary
print(r.summary)      # no recomputation
from functools import cached_property


class Report:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def summary(self):
        print("computing...")
        return {"count": len(self.rows), "total": sum(self.rows)}


r = Report([10, 20, 30])
print(r.summary)
print(r.summary)          # cached
del r.summary             # clear the cache if the data changed
print(r.summary)

cached_property stores the result in the instance dictionary, so later accesses skip the method entirely. Use it only when the underlying data does not change, or clear it when it does.

Properties in inheritance

class Employee:
    def __init__(self, salary):
        self.salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("salary cannot be negative")
        self._salary = value

    @property
    def annual(self):
        return self._salary * 12


class Manager(Employee):
    BONUS = 100_000

    @property
    def annual(self):
        return super().annual + self.BONUS       # extend the parent property


m = Manager(90_000)
print(f"{m.annual:,}")            # 1,180,000

A worked example

class Product:
    """A product whose price and discount stay consistent with each other."""

    def __init__(self, name, price, discount_percent=0):
        self.name = name
        self.price = price
        self.discount_percent = discount_percent

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("price must be a number")
        if value < 0:
            raise ValueError("price cannot be negative")
        self._price = round(float(value), 2)

    @property
    def discount_percent(self):
        return self._discount_percent

    @discount_percent.setter
    def discount_percent(self, value):
        if not 0 <= value <= 90:
            raise ValueError("discount must be between 0 and 90")
        self._discount_percent = value

    @property
    def discount_amount(self):
        return round(self._price * self._discount_percent / 100, 2)

    @property
    def final_price(self):
        return round(self._price - self.discount_amount, 2)

    @property
    def is_on_sale(self):
        return self._discount_percent > 0

    def __repr__(self):
        return f"Product({self.name!r}, {self._price}, {self._discount_percent}%)"


p = Product("Notebook", 250, 20)
print(p)
print(p.discount_amount, p.final_price, p.is_on_sale)

p.price = 300
print(p.final_price)          # recalculated automatically

for bad in [-10, "free"]:
    try:
        p.price = bad
    except (ValueError, TypeError) as error:
        print(f"{bad!r}: {error}")

When not to use a property

# Wrong: a property that is slow or has side effects
class Account:
    @property
    def balance(self):
        return fetch_from_database(self.id)     # a network call behind a dot


# Right: make the cost visible
class Account:
    def fetch_balance(self):
        return fetch_from_database(self.id)

A property should look and feel like an attribute: fast, side effect free, and safe to read twice. Anything that hits a database, a file or a network deserves to be a method, so the reader can see it costs something.

Common mistakes

  • Naming the property and the stored attribute the same, causing infinite recursion:
class Broken:
    @property
    def value(self):
        return self.value        # RecursionError - calls itself forever
  • Forgetting the setter, then being surprised that assignment raises AttributeError.
  • Using @property for something expensive.
  • Writing get_x and set_x methods out of habit from another language.
  • Caching with cached_property and never invalidating it when the data changes.
  • Putting validation only in __init__, so later assignment bypasses it.

Best practices

  • Start with a plain public attribute; add a property only when it earns its place.
  • Store the value under a single underscore name and expose it without.
  • Assign through the property in __init__ so construction is validated too.
  • Use read only properties for derived values.
  • Keep properties cheap, and use a method when the work is real.

Practice

  1. Convert a class using get_name and set_name into one using a property, leaving callers untouched.
  2. Write a Person class with a date_of_birth attribute and a read only age property.
  3. Create a class with two units of the same measurement, kept consistent through properties.
  4. Demonstrate the recursion error caused by naming a property after its own attribute, then fix it.
  5. Add cached_property to an expensive calculation and show how to invalidate it.

Conclusion

@property is why Python does not need getters and setters. Expose attributes directly, and when a rule or a computed value becomes necessary, convert it to a property - the interface stays exactly the same.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.