Properties and Class Methods
The @property decorator
Section titled “The @property decorator”@property turns a method into an attribute-style accessor.
Callers use obj.celsius instead of obj.get_celsius(), keeping the public API clean while allowing validation logic to run behind the scenes.
class Temperature: def __init__(self, celsius: float = 0.0) -> None: self._celsius = celsius # underscore = internal storage
@property def celsius(self) -> float: return self._celsiusSetters
Section titled “Setters”Pair @property with @name.setter to intercept assignment:
@celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = valueNow t.celsius = -300 raises ValueError automatically.
Without a setter, the property is read-only — attempts to assign raise AttributeError.
Computed (derived) properties
Section titled “Computed (derived) properties”A property can compute its value on the fly with no backing storage:
@property def fahrenheit(self) -> float: return self._celsius * 9 / 5 + 32t.fahrenheit looks like a plain attribute to the caller, but it is recalculated each time.
property is a descriptor — write your own in 10 lines
Section titled “property is a descriptor — write your own in 10 lines”@property is not special syntax the interpreter hard-codes — it is a class, property, that implements the descriptor protocol: __get__, __set__, and optionally __delete__. Any class that implements those methods and is stored as a class attribute behaves the same way. Here is a hand-written descriptor that reimplements the validating celsius property from scratch, in about ten lines:
class Celsius: def __set_name__(self, owner: type, name: str) -> None: self.name = f"_{name}" # remembers the attribute name it's bound to
def __get__(self, obj: object, objtype: type | None = None) -> float: if obj is None: return self # accessed on the class itself, e.g. Thermostat.celsius return getattr(obj, self.name, 0.0)
def __set__(self, obj: object, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") setattr(obj, self.name, value)
class Thermostat: celsius = Celsius() # a descriptor instance, used exactly like @propertyt.celsius = 21.0 calls Celsius.__set__; t.celsius calls Celsius.__get__ — identical behavior to @property with a setter, because that’s genuinely what property is under the hood: hasattr(property, "__get__") and hasattr(property, "__set__") are both True. Understanding this explains a lot of “surprising” behavior elsewhere in Python: classmethod, staticmethod, and functions themselves (bound methods) are all descriptors too — attribute access on an instance is really the descriptor protocol running, every time.
functools.cached_property
Section titled “functools.cached_property”A @property recomputes on every access. @cached_property (from functools) computes the value once, on first access, and stores it as a plain entry in the instance’s __dict__ — every access after that is a normal (fast) attribute lookup, not a re-run of the method:
from functools import cached_property
class Report: def __init__(self, rows: list[int]) -> None: self.rows = rows
@cached_property def total(self) -> int: return sum(self.rows) # only runs once per instancer.total accessed twice runs the summing method exactly once; r.__dict__["total"] shows the cached result sitting there as an ordinary attribute after the first access. Because the cache lives in __dict__, a cached_property requires the instance to have one (it will not work on a slots=True class unless "total" is listed as a slot) — and since the cache is just an instance attribute, invalidating it is del obj.total, which forces the next access to recompute.
@classmethod — alternative constructors
Section titled “@classmethod — alternative constructors”A class method receives the class (cls) as its first argument instead of the instance.
The most common use is providing alternative constructors:
@classmethod def from_fahrenheit(cls, f: float) -> Self: return cls((f - 32) * 5 / 9)boiling = Temperature.from_fahrenheit(212)print(boiling.celsius) # 100.0Subclasses automatically inherit the class method and cls will refer to the subclass — so the factory creates an instance of the correct type.
Annotate the return type with typing.Self (from typing import Self), not the string literal "Temperature" — the string annotation makes a type checker infer the base class even when cls is a subclass. With the old annotation, uvx mypy --strict on a subclass that adds a method reports exactly this: "Temperature" has no attribute "to_kelvin" [attr-defined]. Self fixes it because it always refers to whatever class the method was actually called on — confirmed with a real subclass:
class Kelvin(Temperature): def to_kelvin(self) -> float: return self.celsius + 273.15
k = Kelvin.from_fahrenheit(32)print(type(k)) # <class '__main__.Kelvin'>print(k.to_kelvin()) # 273.15 -- only type-checks because from_fahrenheit's return type is Selfk really is a Kelvin at runtime regardless of the annotation — but only the Self annotation lets uvx mypy --strict and uvx pyright both confirm k.to_kelvin() is valid without a manual cast.
@staticmethod — utility functions
Section titled “@staticmethod — utility functions”A static method receives neither the instance nor the class. Use it for utilities that logically belong to the class but do not need access to class or instance state:
@staticmethod def absolute_zero() -> float: return -273.15print(Temperature.absolute_zero()) # -273.15Full runnable demo
Section titled “Full runnable demo”from functools import cached_propertyfrom typing import Self
class Celsius: def __set_name__(self, owner: type, name: str) -> None: self.name = f"_{name}"
def __get__(self, obj: object, objtype: type | None = None) -> float: if obj is None: return self # type: ignore[return-value] return getattr(obj, self.name, 0.0)
def __set__(self, obj: object, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") setattr(obj, self.name, value)
class Thermostat: celsius = Celsius() # a descriptor instance, used exactly like @property
t = Thermostat()t.celsius = 21.0print(t.celsius)try: t.celsius = -300except ValueError as e: print(f"ValueError: {e}")
print(hasattr(Celsius, "__get__"), hasattr(Celsius, "__set__"))print(hasattr(property, "__get__"), hasattr(property, "__set__"))
class Report: def __init__(self, rows: list[int]) -> None: self.rows = rows self.builds = 0
@cached_property def total(self) -> int: self.builds += 1 return sum(self.rows)
r = Report([1, 2, 3])print(r.total)print(r.total)print(r.builds) # computed once -- cached_property stores the result in the instance __dict__print(r.__dict__["total"]) # the cache IS an ordinary instance attribute now
class Temperature: def __init__(self, celsius: float = 0.0) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = value
@property def fahrenheit(self) -> float: return self._celsius * 9 / 5 + 32
@classmethod def from_fahrenheit(cls, f: float) -> Self: return cls((f - 32) * 5 / 9)
@staticmethod def absolute_zero() -> float: return -273.15
t2 = Temperature(100)print(t2.celsius)print(t2.fahrenheit)t2.celsius = 0print(t2.fahrenheit)t3 = Temperature.from_fahrenheit(212)print(t3.celsius)print(Temperature.absolute_zero())
class Kelvin(Temperature): def to_kelvin(self) -> float: return self.celsius + 273.15
k = Kelvin.from_fahrenheit(32)print(type(k))print(k.to_kelvin())Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”flowchart TD
A["obj.attr ="] --> B{"type(obj) defines attr as a data descriptor? (has __set__)"}
B -->|"yes"| C["descriptor.__set__(obj, value) runs -- property setter, custom descriptor"]
B -->|"no"| D["plain assignment into obj.__dict__['attr']"]
E["obj.attr"] --> F{"type(obj) defines attr as a descriptor? (has __get__)"}
F -->|"yes, and it's a DATA descriptor (__set__ too)"| G["descriptor wins even over obj.__dict__"]
F -->|"yes, but non-data (only __get__, e.g. a plain method)"| H["obj.__dict__ wins if present, else descriptor.__get__ runs"]
F -->|"no"| I["obj.__dict__, then the class's MRO"] @property, cached_property, classmethod, staticmethod, and even a plain function stored on a class are all descriptors — the mechanism the classes lesson’s attribute-lookup Mental Model glossed over as “class __dict__”. A data descriptor (defines both __get__ and __set__, like property) takes priority over the instance’s own __dict__ — that’s why a computed @property can’t be shadowed by obj.attr = x the way a plain class attribute can. A non-data descriptor (only __get__, like cached_property before its first access, or a plain method) loses to an instance __dict__ entry with the same name — which is exactly how cached_property “caches”: after the first access it writes an entry into obj.__dict__, and every later lookup finds that entry before the descriptor ever runs again.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
@property | Validation and computed values behind plain-attribute syntax | Recomputes on every access |
| Custom descriptor | Reusable validation/logic shared across many classes and attributes | More boilerplate than a single @property; the protocol (__get__/__set__/__set_name__) has to be learned once |
@cached_property | Computed once, then as fast as a normal attribute | Requires an instance __dict__ (not compatible with slots=True unless the name is a slot); stale cache if the underlying data changes |
@classmethod + Self | Alternative constructors that work correctly for subclasses | Returning the wrong literal type annotation ("Temperature" instead of Self) silently breaks subclass type-checking |
@staticmethod | Groups a utility function under the class namespace | No access to self or cls — indistinguishable from a module-level function except for the namespace |
Common Misconceptions
Section titled “Common Misconceptions”@propertyis special interpreter syntax, unrelated to normal classes. It’s an ordinary built-in class implementing the descriptor protocol (__get__/__set__) — you can write your own version in about ten lines.- A property without a setter can still be assigned, just without validation. It can’t be assigned at all — attempting
obj.attr = xon a read-only property raisesAttributeError, not a silent no-op. cached_propertyre-runs its function every time, just faster. It runs exactly once — the first access writes the value into the instance__dict__, and every access after that is a normal dict lookup that never calls the function again.- Annotating a classmethod’s return type as the literal class name (
-> "Temperature") is just as correct asSelf. A type checker takes the literal name at face value even whenclsis a subclass, producing falseattr-definederrors on subclass-only methods;Selftracks the actual calling class. @staticmethodand a module-level function are meaningfully different at runtime. They behave the same when called — the only difference is namespacing (ClassName.util()vs a free function) and discoverability.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated properties and class methods, check for:
- A
@classmethodalternative constructor annotated-> "ClassName"(a string / forward reference to the literal class) instead of-> Self— this silently breaks type-checking the moment someone subclasses it. - A
@cached_propertyon a class withslots=Trueand no slot named for the cached attribute — this raisesAttributeError: 'ClassName' object has no attribute '__dict__'at first access, since there’s nowhere to store the cached value. - Business logic or side effects (network calls, mutation of unrelated state) hidden inside a
@propertygetter — a caller readingobj.attrhas no syntactic hint that it might be slow or fail. - A hand-written descriptor’s
__get__that doesn’t handleobj is None(the case where the descriptor is accessed on the class itself, e.g.Thermostat.celsius) — it should returnself(or a similar sentinel) rather than crashing. - A
@staticmethodthat referencesselforclsat all — that’s a sign it should be an instance or class method instead.
🐛 Spot the bug
class Config: def __init__(self) -> None: self._data = {"retries": 3}
@property def retries(self) -> int: return self._data["retries"]
@classmethod def from_env(cls) -> "Config": return cls()
class StrictConfig(Config): def validate(self) -> bool: return self.retries > 0
cfg = StrictConfig.from_env()cfg.validate()Show the answer
from_env is annotated -> "Config" instead of -> Self. At runtime StrictConfig.from_env() really does return a StrictConfig, so cfg.validate() runs fine — but a type checker takes the literal annotation at face value and infers cfg: Config, which has no validate method. Verified: uvx mypy --strict on this file reports "Config" has no attribute "validate" [attr-defined], and uvx pyright reports the equivalent reportAttributeAccessIssue, even though the code runs correctly. Fix it by importing Self from typing and annotating from_env(cls) -> Self.