Skip to content
elephantoo

Properties, class & static methods

Lesson 22 of 38 15 min read

@property getters and setters, cached_property, alternative constructors with @classmethod and @staticmethod.


In Java you'd write getBalance() and setBalance() for every field, "just in case". Python doesn't need that ceremony. You start with plain attributes, and if you later need validation or computed values, you turn the attribute into a property — without changing any code that uses the class. This lesson also covers the two other kinds of methods: class methods and static methods.

The problem: attributes with rules#

Python
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price


p = Product("Mouse", 799)
p.price = -50          # nothing stops this
print(p.price)
Output
-50

A negative price is a bug waiting to happen. You could make price private and add get_price() / set_price(), but then every caller must change, and code becomes noisier.

@property: computed attributes#

The @property decorator turns a method into a read-only attribute that is computed each time it's accessed:

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

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

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


r = Rectangle(3, 4)
print(r.area, r.is_square)       # no parentheses
r.width = 4
print(r.area, r.is_square)       # always up to date
Output
12 False
16 True

Because area is computed from width and height, it can never get out of sync. Assigning to it fails, since there's no setter:

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

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


Rectangle(3, 4).area = 100
Output
AttributeError: property 'area' of 'Rectangle' object has no setter

Setters: validation on assignment#

Add a setter with @<name>.setter. The real value is stored in an "internal" attribute, conventionally the same name with a leading underscore:

Python
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price            # goes through the setter below!

    @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(f"price can't be negative: {value}")
        self._price = value


p = Product("Mouse", 799)
p.price = 699                         # looks like a normal assignment
print(p.price)

for bad in (-50, "free"):
    try:
        p.price = bad
    except (ValueError, TypeError) as e:
        print(type(e).__name__, "-", e)

try:
    Product("Keyboard", -1)           # validated in __init__ too
except ValueError as e:
    print("ValueError -", e)
Output
699
ValueError - price can't be negative: -50
TypeError - price must be a number
ValueError - price can't be negative: -1

Notice that __init__ assigns self.price, not self._price, so the validation applies from the moment the object is created. Callers never knew anything changed — p.price = 699 works exactly as before. That's why Python developers happily start with public attributes.

You can also add a @price.deleter, though it's rarely needed.

Derived properties with setters#

A setter can update the underlying data in a different unit:

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

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

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


t = Temperature(25)
print(t.fahrenheit)
t.fahrenheit = 212
print(t.celsius)
Output
77.0
100.0

Caching expensive computations#

If a property is expensive and its inputs never change, functools.cached_property computes it once and stores the result on the instance:

Python
from functools import cached_property


class Dataset:
    def __init__(self, numbers):
        self.numbers = numbers

    @cached_property
    def stats(self):
        print("computing...")
        n = len(self.numbers)
        mean = sum(self.numbers) / n
        return {"n": n, "mean": mean}


d = Dataset([3, 5, 7, 9])
print(d.stats)
print(d.stats["mean"])     # cached: no "computing..." this time
Output
computing...
{'n': 4, 'mean': 6.0}
6.0

Class methods: alternative constructors#

A regular method receives the instance (self). A class method receives the class (cls). Their most common job is providing alternative constructors — other ways to create an object:

Python
from datetime import date


class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

    @classmethod
    def from_string(cls, text):
        name, year = text.split(",")
        return cls(name.strip(), int(year))      # cls, not Person!

    @classmethod
    def from_age(cls, name, age):
        return cls(name, date.today().year - age)

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r}, {self.birth_year})"


print(Person.from_string("Ada Lovelace, 1815"))
print(Person.from_age("Sam", 30).name)


class Student(Person):
    pass


print(Student.from_string("Linus, 1969"))   # returns a Student, thanks to cls
Output
Person('Ada Lovelace', 1815)
Sam
Student('Linus', 1969)

You've already used built-in class methods like dict.fromkeys(), datetime.fromisoformat() and int.from_bytes(). Using cls(...) instead of the hard-coded class name means subclasses get instances of the right type.

Class methods can also manage class-level state, such as a registry or counter.

Static methods#

A static method receives neither the instance nor the class. It's a plain function that lives inside the class because it belongs there logically:

Python
class Password:
    MIN_LENGTH = 8

    def __init__(self, text):
        if not Password.is_strong(text):
            raise ValueError("password too weak")
        self._text = text

    @staticmethod
    def is_strong(text):
        return (
            len(text) >= Password.MIN_LENGTH
            and any(c.isdigit() for c in text)
            and any(c.isupper() for c in text)
        )


print(Password.is_strong("hunter2"))
print(Password.is_strong("Tr0ub4dor&3"))
Output
False
True

If a static method doesn't really belong to the class, a module-level function is just as good — Python doesn't force everything into classes.

Choosing the right kind of method#

KindFirst argumentUse it for
instance methodself (the object)anything that reads or changes one object
@classmethodcls (the class)alternative constructors, class-wide state
@staticmethodnonehelpers related to the class
@propertyselfcomputed values and validated attributes

Worked example: a validated bank account#

Python
class Account:
    interest_rate = 0.035

    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = 0
        self.deposit(balance)

    @property
    def balance(self):                 # read-only from outside
        return self._balance

    def deposit(self, amount):
        if amount < 0:
            raise ValueError("amount must be non-negative")
        self._balance += amount

    @property
    def yearly_interest(self):
        return round(self._balance * self.interest_rate, 2)

    @classmethod
    def set_rate(cls, rate):
        cls.interest_rate = rate

    @staticmethod
    def format_inr(amount):
        return f"₹{amount:,.2f}"


a = Account("Ada", 100_000)
print(Account.format_inr(a.balance), Account.format_inr(a.yearly_interest))
Account.set_rate(0.04)
print(Account.format_inr(a.yearly_interest))
try:
    a.balance = 10**9
except AttributeError:
    print("balance is read-only")
Output
₹100,000.00 ₹3,500.00
₹4,000.00
balance is read-only

Common mistakes#

  • Infinite recursion in a setter: writing self.price = value inside the price setter calls the setter again forever. Store into self._price.
  • Adding parentheses: r.area() fails with TypeError: 'int' object is not callable because r.area is already the value.
  • Slow or side-effecting properties: readers expect attribute access to be cheap and harmless. Use a method for expensive work or actions (load(), send()).
  • Hard-coding the class name in class methods instead of using cls.
  • Writing getters and setters for everything — use plain attributes until you need logic.

What's next#

Writing __init__, __repr__ and __eq__ by hand gets repetitive. Next, dataclasses generate all of that for you.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does the @property decorator let you do?

  2. 2.What is the first parameter of a @classmethod?

  3. 3.When is a @staticmethod appropriate?

Finished reading?

Mark this lesson complete to track your progress.