Skip to content

Properties and Class Methods

@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._celsius

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 = value

Now t.celsius = -300 raises ValueError automatically. Without a setter, the property is read-only — attempts to assign raise AttributeError.

A property can compute its value on the fly with no backing storage:

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

t.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 @property

t.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.

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 instance

r.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.

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.0

Subclasses 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 Self

k 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.

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.15
print(Temperature.absolute_zero()) # -273.15
from functools import cached_property
from 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.0
print(t.celsius)
try:
t.celsius = -300
except 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 = 0
print(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())
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"]
Descriptors sit between plain attribute lookup and the instance __dict__

@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.

What you getBenefitCost
@propertyValidation and computed values behind plain-attribute syntaxRecomputes on every access
Custom descriptorReusable validation/logic shared across many classes and attributesMore boilerplate than a single @property; the protocol (__get__/__set__/__set_name__) has to be learned once
@cached_propertyComputed once, then as fast as a normal attributeRequires an instance __dict__ (not compatible with slots=True unless the name is a slot); stale cache if the underlying data changes
@classmethod + SelfAlternative constructors that work correctly for subclassesReturning the wrong literal type annotation ("Temperature" instead of Self) silently breaks subclass type-checking
@staticmethodGroups a utility function under the class namespaceNo access to self or cls — indistinguishable from a module-level function except for the namespace
  • @property is 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 = x on a read-only property raises AttributeError, not a silent no-op.
  • cached_property re-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 as Self. A type checker takes the literal name at face value even when cls is a subclass, producing false attr-defined errors on subclass-only methods; Self tracks the actual calling class.
  • @staticmethod and 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.

Before trusting generated properties and class methods, check for:

  • A @classmethod alternative 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_property on a class with slots=True and no slot named for the cached attribute — this raises AttributeError: '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 @property getter — a caller reading obj.attr has no syntactic hint that it might be slow or fail.
  • A hand-written descriptor’s __get__ that doesn’t handle obj is None (the case where the descriptor is accessed on the class itself, e.g. Thermostat.celsius) — it should return self (or a similar sentinel) rather than crashing.
  • A @staticmethod that references self or cls at 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.

What does @property do to a method?
How do you make a @property writable?
What is the first parameter of a @classmethod?
When is @staticmethod the right choice?
What relationship does @property have to the descriptor protocol?
What does this print?
class Report:
    def __init__(self, rows):
        self.rows = rows
        self.calls = 0

    @cached_property
    def total(self):
        self.calls += 1
        return sum(self.rows)

r = Report([1, 2, 3])
r.total
r.total
print(r.calls)
What does uvx mypy --strict report?
class Base:
    @classmethod
    def make(cls) -> "Base":
        return cls()

class Sub(Base):
    def extra(self) -> str:
        return "hi"

s = Sub.make()
s.extra()
Why can a hand-written descriptor's __get__ be called with obj=None?