A property lets you write a method that looks like a regular attribute from the outside. Useful when you want to compute a value on the fly, or run some logic when an attribute is read or written.

A computed attribute

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height

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


r = Rectangle(3.0, 4.0)
print(r.area)        # 12.0   — looks like an attribute, but it's computed

Two things to notice:

  • @property on the line above the method makes it act like an attribute.
  • The caller writes r.area (no parentheses), even though it’s a method underneath.

Whenever you’d write a no-argument getter — r.get_area() — consider a @property instead. It reads more naturally.

Why not just store it?

You could just compute the area in __init__:

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height
        self.area = width * height

The catch — if you later change width or height, area doesn’t update:

r = Rectangle(3.0, 4.0)
r.width = 10.0
print(r.area)        # 12.0 — stale!

A property always recomputes when read:

r.width = 10.0
print(r.area)        # 40.0 — fresh

When the value depends on others, make it a property.

Read-only attributes

A property by itself is read-only — you can’t assign to it:

r = Rectangle(3.0, 4.0)
r.area = 99.0        # AttributeError: property 'area' has no setter

This is a feature. It signals to callers: “you can’t change this directly”.

Adding a setter

If you want a property you can also write to, add a setter:

class Temperature:
    def __init__(self, celsius: float) -> 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


t = Temperature(25)
print(t.celsius)        # 25
print(t.fahrenheit)     # 77.0

t.celsius = 30          # uses the setter, validates
print(t.fahrenheit)     # 86.0

t.celsius = -300        # ValueError

The setter:

  • Is decorated with @celsius.setter — the property’s name dot setter.
  • Has the same name as the property.
  • Receives the assigned value as a parameter.

This pattern lets you validate values whenever they’re set, without callers having to remember a special method.

Sub-pattern — _name for the storage, name for the property

Notice we used self._celsius to store the value and celsius as the property. The underscore is the convention: “the real storage is private; access goes through the property”.

This is so common that you’ll see this pattern in every Python codebase that uses properties.

When to use properties

  • The attribute is computed from others
  • You want to validate values when they’re written
  • You want to transform values (e.g. store internally as cents, expose as dollars)
  • You want a read-only attribute

Skip the property when a plain attribute works. Don’t add ceremony for no reason.

A subtle gotcha — properties on dataclasses

We’ll meet dataclasses in lesson 6. Properties on dataclasses are tricky because dataclasses also generate attribute logic. If you need a computed value on a dataclass, the cleanest approach is usually a method or a @cached_property (from functools) — not a regular @property.

For most beginner use cases, you won’t hit this — but the warning is here so you don’t waste time later.

What’s next

You can shape how attributes look from the outside. Next, inheritance — building one class on top of another.

Toggle theme (T)