Class attributes, properties, classmethods, and staticmethods¶
The supporting cast. Once you have classes, instances, dunders, and maybe some inheritance, there's a handful of smaller features that round out what you can express: attributes that belong to the class rather than an instance, attributes that run code when read or written, and methods that don't take self.
None of these are showstoppers — you could write an entire application without reaching for any of them — but each one has a natural use case, and knowing when to reach for what keeps your classes readable.
Class attributes vs instance attributes¶
Everything set on self inside __init__ is an instance attribute — each instance has its own. You can also set attributes on the class itself, outside any method. Those are class attributes, shared across all instances.
class Circle:
pi = 3.14159 # class attribute — shared
def __init__(self, radius):
self.radius = radius # instance attribute — per-instance
def area(self):
return Circle.pi * self.radius ** 2
print(Circle.pi) # accessible on the class
c = Circle(5)
print(c.pi) # also accessible via an instance
print(c.area())
Two rules of thumb:
- Use class attributes for true constants that belong to the type: a
DEFAULT_TIMEOUT, aMAX_RETRIES, a lookup table. Anything that every instance will share. - Set everything per-instance in
__init__. If two instances should hold different values, it must be an instance attribute.
The mutable class attribute trap¶
This is the single most common class-attribute bug. If the class attribute is a mutable object, every instance shares the same object:
class Basket:
items = [] # mutable class attribute — SHARED!
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items) # ['apple'] — b sees a's items
The fix is to set mutable attributes per-instance in __init__:
class Basket:
def __init__(self):
self.items = [] # per-instance
def add(self, item):
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(a.items, b.items)
@dataclass with field(default_factory=list) — from the data classes notebook — solves the same problem more elegantly when you're defining many fields.
@property — computed and validated attributes¶
@property turns a method into something that looks like an attribute. Reading c.diameter runs the method; you don't type the parentheses.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def diameter(self):
return self.radius * 2
c = Circle(5)
print(c.diameter) # no parentheses — looks like an attribute
That's useful when a value is cheap to compute and always derivable from the state you already have. You save callers from having to remember whether it's an attribute or a method, and if the internal representation changes later, callers don't notice.
Add a setter and the attribute becomes writable, with your code running on every assignment. Use this for validation — enforce invariants even when callers assign directly:
class Circle:
def __init__(self, radius):
self.radius = radius # triggers the setter
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
c = Circle(5)
c.radius = 10 # works
try:
c.radius = -1 # caught by setter
except ValueError as e:
print(f"{type(e).__name__}: {e}")
The underlying storage lives on self._radius (leading underscore by convention — "don't poke at this directly"). The public name radius is the property.
Resist the urge to add properties everywhere. If all your getter does is return self._x and all your setter does is self._x = value, that's just an attribute with extra ceremony. Add properties only when you need validation, lazy computation, or an interface-preserving façade over storage that's likely to change.
@classmethod — alternative constructors and class-level operations¶
A classmethod's first parameter is cls, the class itself, rather than self. The most common use is an alternative constructor — a factory method that builds an instance from some non-standard input.
from dataclasses import dataclass
@dataclass
class Date:
year: int
month: int
day: int
@classmethod
def from_iso(cls, s):
year, month, day = s.split("-")
return cls(int(year), int(month), int(day))
d = Date.from_iso("2026-04-21")
print(d)
cls(...) is how you construct an instance. Using cls (rather than hard-coding Date(...)) means subclasses get the right behaviour — if someone defines class BusinessDate(Date), BusinessDate.from_iso("...") returns a BusinessDate, not a Date.
@staticmethod — a function that happens to live in the class¶
A staticmethod has neither self nor cls. It's a plain function — the only reason it lives on the class is namespacing.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@staticmethod
def celsius_to_fahrenheit(c):
return c * 9 / 5 + 32
print(Temperature.celsius_to_fahrenheit(100))
Staticmethods are useful for utility functions that are clearly associated with the class but don't need access to an instance or the class itself. If you find yourself reaching for @staticmethod frequently, consider whether a module-level function would be cleaner — classes aren't just namespaces.
Choosing between the three¶
| Decorator | First arg | Use when |
|---|---|---|
| plain method | self |
You need the instance's state. |
@classmethod |
cls |
You need the class itself — typically for an alternative constructor. |
@staticmethod |
none | The method belongs on the class by topic, not by needing any state. |
@property |
self |
You want attribute-style access to a computed or validated value. |
Exercise¶
Build an Account class with:
- Class attribute
MINIMUM_BALANCE = 0(overridable by subclasses). __init__(self, owner, balance=0).- A
balanceproperty with a setter that rejects values belowMINIMUM_BALANCE. - A
classmethodfrom_dict(cls, data)that builds an account from a dictionary like{"owner": "Ada", "balance": 100}. - A
staticmethodis_valid_owner_name(name)that returns True ifnameis a non-empty string.
Test each piece: create an account, try setting a negative balance, build one from a dict, validate a name.
# Your code here
Solution
class Account:
MINIMUM_BALANCE = 0
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance # triggers the setter
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < self.MINIMUM_BALANCE:
raise ValueError(
f"balance cannot go below {self.MINIMUM_BALANCE}"
)
self._balance = value
@classmethod
def from_dict(cls, data):
return cls(owner=data["owner"], balance=data["balance"])
@staticmethod
def is_valid_owner_name(name):
return isinstance(name, str) and len(name.strip()) > 0
a = Account.from_dict({"owner": "Ada", "balance": 100})
print(a.balance)
print(Account.is_valid_owner_name("Ada"))
Recap¶
- Class attributes are shared across instances. Use them for true constants. Avoid mutable ones — set mutable state per-instance in
__init__. @propertylets a method pretend to be an attribute. Reach for it when you want computed values or validation on assignment.@classmethodtakesclsinstead ofself. The canonical use is alternative constructors.@staticmethodtakes neither. Use it sparingly — module-level functions are often cleaner.
You've now seen enough to write classes that feel at home in Python. The Recipes section covers specific tasks in more depth, and the Reference section is the lookup for dunders and decorator options.