Properties, class methods and static methods
Hide internals by convention, expose computed and validated attributes with @property, and choose between instance, class and static methods.
What you will learn
- Mark internal attributes with a leading underscore and explain what that does and does not enforce
- Turn a method into a read-only attribute with @property
- Validate assignments with a property setter
- Write a @classmethod alternative constructor and a @staticmethod helper, and say when to pick each
On this page
The previous lesson gave Account attributes you could read and write freely. That is convenient, and it is also how a balance ends up negative. This lesson shows how to keep that convenience while the class keeps control of its own data.
Encapsulation by convention
Python has no private keyword. Instead, a name that starts with one underscore tells readers: “this is an internal detail, please use the public methods instead.”
class Account: def __init__(self, owner, balance): self.owner = owner self._balance = balance # the underscore says: internal
def deposit(self, amount): self._balance = self._balance + amount
def balance(self): return self._balance
acct = Account("Asha", 100)acct.deposit(50)print(acct.balance())print(acct._balance) # allowed, but you are on your own150150The last line works. Python does not enforce the underscore, and nothing stops outside code from reading or changing _balance. The convention still matters: it separates the part of the class you promise to keep stable from the part you may rewrite tomorrow. Tools such as linters and editors respect it, and so do careful colleagues.
Computed attributes with @property
Sometimes a value should look like an attribute but be worked out on demand. A method decorated with @property is read without parentheses:
import math
class Circle: def __init__(self, radius): self.radius = radius
@property def area(self): return math.pi * self.radius**2
c = Circle(2)print(round(c.area, 2))c.radius = 3print(round(c.area, 2))12.5728.27c.area runs the method every time. After radius changed to 3, the next read gave the new area, because nothing was stored. The caller wrote c.area, not c.area(), so you can later change area from a stored attribute to a computed one, or the other way round, without touching any calling code.
A property with only a getter is read-only:
import math
class Circle: def __init__(self, radius): self.radius = radius
@property def area(self): return math.pi * self.radius**2
c = Circle(2)try: c.area = 10except AttributeError as error: print("AttributeError:", error)AttributeError: property 'area' of 'Circle' object has no setterThe wording of that message belongs to recent Python versions. Older ones say only can't set attribute, so match on the exception type, not the text.
Validating assignments with a setter
To accept assignments but check them, add a second method with the same name, decorated with @name.setter. Store the real value under an underscore name, so the property and the stored value do not collide.
class Temperature: def __init__(self, celsius): self.celsius = celsius # goes through the setter
@property def celsius(self): return self._celsius
@celsius.setter def celsius(self, value): if value < -273.15: raise ValueError(f"below absolute zero: {value}") self._celsius = value
@property def fahrenheit(self): return self._celsius * 9 / 5 + 32
t = Temperature(100)print(t.celsius, t.fahrenheit)t.celsius = 0print(t.celsius, t.fahrenheit)try: t.celsius = -300except ValueError as error: print("ValueError:", error)try: Temperature(-500)except ValueError as error: print("ValueError:", error)100 212.00 32.0ValueError: below absolute zero: -300ValueError: below absolute zero: -500Look at __init__. It assigns self.celsius = celsius, not self._celsius, so even the first value goes through the setter and the check. That is why Temperature(-500) fails at construction. The fahrenheit property has no stored value of its own. It is always derived from _celsius, so the two can never disagree.
Three kinds of method
So far every method has taken self. Two other kinds exist, and each answers a different question.
| Kind | First parameter | Use it when |
|---|---|---|
| Instance method | self, the instance |
the method works with one object’s data |
@classmethod |
cls, the class |
the method works with the class itself, for example to build instances |
@staticmethod |
nothing extra | the function is related to the class but needs neither the class nor an instance |
Class methods as alternative constructors
A class has one __init__, but there are often several natural ways to start from other data. A class method that returns a new instance gives each way a name:
class Account: def __init__(self, owner, balance): self.owner = owner self.balance = balance
@classmethod def from_text(cls, text): owner, balance = text.split(":") return cls(owner, int(balance))
acct = Account.from_text("Asha:100")print(acct.owner, acct.balance)Asha 100Python passes the class as cls, and cls(owner, int(balance)) is the same as Account(owner, int(balance)). Writing cls rather than the class name is not decoration. It makes the method work for subclasses:
class Shape: def __init__(self, name): self.name = name
@classmethod def named(cls, name): return cls(name)
class Square(Shape): pass
s = Square.named("tile")print(type(s).__name__, s.name)Square tileSquare.named("tile") built a Square, not a Shape, because cls was Square. You will see the same from_... naming in the standard library, for example dict.fromkeys and datetime.fromisoformat.
Static methods as namespaced helpers
A static method is an ordinary function that lives in the class’s namespace, because it belongs there conceptually:
class Account: def __init__(self, owner, balance): self.owner = owner self.balance = balance
@staticmethod def is_valid_owner(name): return name.strip() != ""
print(Account.is_valid_owner("Asha"))print(Account.is_valid_owner(" "))TrueFalseIt receives neither self nor cls. If you find a static method that never mentions the class, ask whether it would be clearer as a plain function in the module. Both choices are fine, and keeping the helper next to the class is the whole point.
Here are the three side by side, called through an instance and through the class:
class Demo: count = 0
def instance_method(self): return f"instance method on {self!r}"
@classmethod def class_method(cls): return f"class method on {cls.__name__}"
@staticmethod def static_method(): return "static method, no self or cls"
d = Demo()print(d.class_method())print(Demo.class_method())print(Demo.static_method())print(d.instance_method().startswith("instance method on <"))class method on Democlass method on Demostatic method, no self or clsTrueCommon pitfalls
A setter that assigns to its own name. Inside the setter for celsius, self.celsius = value calls the setter again, which assigns again, and so on:
class Temperature: def __init__(self, celsius): self.celsius = celsius
@property def celsius(self): return self._celsius
@celsius.setter def celsius(self, value): self.celsius = value # bug: calls this setter again
try: Temperature(20)except RecursionError: print("RecursionError: the setter called itself forever")RecursionError: the setter called itself foreverStore the value under a different name, self._celsius, as the working example did.
Expecting the underscore to protect anything. _balance is a request, not a lock. If a rule must hold, put the check in a property or method, and treat direct access to the underscore name as the caller’s own risk.
Calling a property with parentheses. c.area() fails, because c.area is already a number and a number cannot be called. The error reads 'float' object is not callable.
Heavy work in a property. Callers expect obj.name to be cheap. If the computation is slow, or can fail in surprising ways, a method such as obj.compute_name() tells the reader to expect work.
Start from the Temperature class above, with a validated celsius property and a read-only fahrenheit property. Make fahrenheit settable too: assigning to it should convert the value to Celsius and store that, going through the same check.
Run this script:
t = Temperature(25)print(t.fahrenheit)t.fahrenheit = 212print(t.celsius)try: t.fahrenheit = -500except ValueError as error: print("ValueError:", error)print(t.celsius)Expected output:
77.0100.0ValueError: below absolute zero: -295.55555555555554100.0Show solution
class Temperature: def __init__(self, celsius): self.celsius = celsius
@property def celsius(self): return self._celsius
@celsius.setter def celsius(self, value): if value < -273.15: raise ValueError(f"below absolute zero: {value}") self._celsius = value
@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 = 212print(t.celsius)try: t.fahrenheit = -500except ValueError as error: print("ValueError:", error)print(t.celsius)77.0100.0ValueError: below absolute zero: -295.55555555555554100.0The Fahrenheit setter does the arithmetic and then assigns to self.celsius, so the Celsius setter’s check runs for free. Because the check fires before _celsius is replaced, the failed assignment leaves the old value, 100.0, in place.
Add a class method from_text(cls, text) to Account. It takes text in the form owner:balance, trims spaces around the owner, and returns a new account whose balance is an integer. Text without a colon raises ValueError with the message expected 'owner:balance', got 'Chi' (using the offending text).
Run it on the three lines "Asha:100", "Ben : 20" and "Chi", printing the owner and balance or the error. Expected output:
Asha 100Ben 20error: expected 'owner:balance', got 'Chi'Show solution
class Account: def __init__(self, owner, balance): self.owner = owner self.balance = balance
@classmethod def from_text(cls, text): if ":" not in text: raise ValueError(f"expected 'owner:balance', got {text!r}") owner, balance = text.split(":") return cls(owner.strip(), int(balance))
for line in ["Asha:100", "Ben : 20", "Chi"]: try: acct = Account.from_text(line) print(acct.owner, acct.balance) except ValueError as error: print("error:", error)Asha 100Ben 20error: expected 'owner:balance', got 'Chi'int(" 20") accepts surrounding spaces, so only the owner needed strip(). The check for the colon comes first, so "Chi" never reaches the unpacking line that would have failed with a less helpful error.
Write down what this prints, then run it. Which line shows that a property without a setter is read-only?
class Box: def __init__(self, side): self.side = side
@property def volume(self): return self.side**3
b = Box(2)print(b.volume)b.side = 3print(b.volume)try: b.volume = 100except AttributeError: print("read-only")print(b.volume)Show solution
827read-only27The volume is computed on each read, so after side becomes 3 it is 27. The assignment b.volume = 100 raises AttributeError, which the script reports as read-only, and the final read shows the volume is still 27.
Summary
- A leading underscore marks an internal name. Python does not enforce it, so it documents your intent for readers and tools.
@propertyturns a method into an attribute that is computed on each read. Without a setter, assignment raisesAttributeError.@name.setteradds validation to assignment. Keep the real value under an underscore name, and assign through the property in__init__so the first value is checked too.- A
@classmethodreceives the class asclsand is the usual way to add alternative constructors that also work for subclasses. - A
@staticmethodreceives nothing extra. It is a plain function stored in the class for organisation.