Skip to content

@property Decorators#

The @property decorator turns a method into a managed attribute: callers use dot syntax (obj.balance) while you control validation, computation, and storage behind the scenes. It is the Pythonic alternative to Java-style getters and setters — and one of the most common OOP patterns in production code and interviews.

This page deepens the preview in Classes and OOP Basics and Introdicion to OOPs.

How to use this page

Read after intermediate OOP. Related: Encapsulation and Access Modifiers, Decorators.

At a glance
Track Python Advanced → Object Oriented Programming
Sections 12 major topics
Outline Use the right-hand TOC to jump

Topics: What @property actually does · Three legitimate use cases · Read-only properties · @property vs public attributes — when to use which · property() as a function — alternative style · Chaining getter, setter, deleter on one method body · functools.cached_property — lazy, once-per-instance · Properties on abstract base classes · … (+4 more)

  1. What @property actually does
  2. Three legitimate use cases
  3. Read-only properties
  4. @property vs public attributes — when to use which
  5. property() as a function — alternative style
  6. Chaining getter, setter, deleter on one method body
  7. functools.cached_property — lazy, once-per-instance
  8. Properties on abstract base classes
  9. Property inheritance and overriding
  10. Descriptor protocol — how lookup interacts with @property
  11. Common patterns in interviews
  12. @property vs @classmethod / @staticmethod

What @property actually does#

Under the hood, @property creates a descriptor — an object with __get__, and optionally __set__ and __delete__. When Python sees obj.name, it checks the class for a descriptor named name before falling back to obj.__dict__.

class Circle:
    def __init__(self, radius: float):
        self._radius = radius

    @property
    def radius(self) -> float:
        """Getter — called on read."""
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        """Setter — called on assignment."""
        if value <= 0:
            raise ValueError("radius must be positive")
        self._radius = value

    @property
    def area(self) -> float:
        """Read-only — no setter defined."""
        return 3.14159 * self._radius ** 2

c = Circle(5)
c.radius          # 5.0 — calls getter
c.radius = 10     # calls setter
# c.area = 100    # AttributeError — no setter
Component Decorator Triggered by
Getter @property obj.attr
Setter @attr.setter obj.attr = value
Deleter @attr.deleter del obj.attr

Three legitimate use cases#

1. Validation on assignment#

class BankAccount:
    def __init__(self, balance: float = 0):
        self.balance = balance   # routes through setter

    @property
    def balance(self) -> float:
        return self._balance

    @balance.setter
    def balance(self, amount: float) -> None:
        if amount < 0:
            raise ValueError("balance cannot be negative")
        self._balance = amount

2. Computed (derived) values#

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

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

    @property
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

Computed properties stay in sync automatically — no manual update_area() calls.

3. Lazy or cached computation#

class DataFrame:
    def __init__(self, rows: list[list[float]]):
        self._rows = rows
        self._mean_cache: float | None = None

    @property
    def mean(self) -> float:
        if self._mean_cache is None:
            flat = [x for row in self._rows for x in row]
            self._mean_cache = sum(flat) / len(flat)
        return self._mean_cache

For heavier caching, consider @functools.cached_property (Python 3.8+) — see below.


Read-only properties#

Omit the setter to make a property read-only:

class Person:
    def __init__(self, birth_year: int):
        self._birth_year = birth_year

    @property
    def birth_year(self) -> int:
        return self._birth_year

    @property
    def age(self) -> int:
        import datetime
        return datetime.date.today().year - self._birth_year

Attempting assignment raises AttributeError: can't set attribute.


@property vs public attributes — when to use which#

Situation Use
Simple data, no invariants Public attribute (self.name = name)
Validation on write @property + setter
Value derived from other fields @property (read-only)
API stability — may add logic later @property over _field
Interview one-liner class Public attributes unless problem says otherwise

Don't wrap every field in @property — that is Java thinking transplanted into Python. Properties earn their cost when they do something.


property() as a function — alternative style#

Before decorator syntax, properties were built with the property() builtin:

class Temperature:
    def __init__(self, celsius: float):
        self._celsius = celsius

    def get_c(self) -> float:
        return self._celsius

    def set_c(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    celsius = property(get_c, set_c)

The decorator form is equivalent and preferred:

@property
def celsius(self) -> float: ...

@celsius.setter
def celsius(self, value: float) -> None: ...

Both compile to the same descriptor object.


Chaining getter, setter, deleter on one method body#

You can attach all three to the same function name only via the functional form. With decorators, each piece is a separate method — the getter method's name becomes the property name.

class Tag:
    def __init__(self):
        self._tags: list[str] = []

    @property
    def tags(self) -> list[str]:
        return self._tags.copy()   # return copy — protect internal list

    @tags.setter
    def tags(self, value: list[str]) -> None:
        if not isinstance(value, list):
            raise TypeError("tags must be a list")
        self._tags = value

    @tags.deleter
    def tags(self) -> None:
        self._tags.clear()

functools.cached_property — lazy, once-per-instance#

Added in Python 3.8; stores the result in instance.__dict__ after first access:

from functools import cached_property

class Graph:
    def __init__(self, edges: list[tuple[int, int]]):
        self.edges = edges

    @cached_property
    def adjacency(self) -> dict[int, list[int]]:
        adj: dict[int, list[int]] = {}
        for u, v in self.edges:
            adj.setdefault(u, []).append(v)
        return adj
Feature @property @cached_property
Recomputes every access Yes (unless you cache manually) No — cached after first call
Stored in __dict__ Only if you assign Automatically
Thread safety Your responsibility Not thread-safe by default
Works on class (3.12+) N/A @classmethod + @cached_property

Properties on abstract base classes#

Abstract properties enforce that subclasses implement the getter (and setter if abstract):

from abc import ABC, abstractmethod

class Shape(ABC):
    @property
    @abstractmethod
    def area(self) -> float: ...

class Square(Shape):
    def __init__(self, side: float):
        self.side = side

    @property
    def area(self) -> float:
        return self.side ** 2

Details: Abstract Base Classes.


Property inheritance and overriding#

Subclasses inherit properties and can override them:

class Base:
    @property
    def value(self) -> int:
        return 1

class Derived(Base):
    @property
    def value(self) -> int:
        return super().value * 2

Derived().value   # 2

Override the setter independently — if the subclass defines only a getter, the parent's setter still applies (and vice versa), which can produce surprising combinations. Override both when changing behavior.


Descriptor protocol — how lookup interacts with @property#

Attribute access order for obj.attr:

1. type(obj).__dict__['attr'].__get__(obj, type(obj))   # data descriptor (has __set__)
2. obj.__dict__['attr']                                   # instance dict
3. type(obj).__dict__['attr'].__get__(obj, type(obj))   # non-data descriptor (property without setter)
4. type(obj).__dict__['attr']                             # class attribute
5. __getattr__ / __getattribute__ hooks
6. AttributeError

Interview trap — instance dict shadows read-only property

class Demo:
    @property
    def x(self) -> int:
        return 42

d = Demo()
d.__dict__["x"] = 99
d.x   # 99 — instance dict wins over non-data descriptor!
This is rare in practice but explains why __dict__ inspection matters in debugging.


Common patterns in interviews#

Fahrenheit ↔ Celsius converter#

class Temperature:
    def __init__(self, celsius: float = 0):
        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("below absolute zero")
        self._celsius = value

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

    @fahrenheit.setter
    def fahrenheit(self, value: float) -> None:
        self.celsius = (value - 32) * 5 / 9

Bounded numeric property (reusable pattern)#

def bounded(min_val: float, max_val: float):
    def decorator(func):
        name = func.__name__
        @property
        def getter(self):
            return getattr(self, f"_{name}")
        @getter.setter
        def setter(self, value):
            if not min_val <= value <= max_val:
                raise ValueError(f"{name} must be in [{min_val}, {max_val}]")
            setattr(self, f"_{name}", value)
        return getter
    return decorator

class Slider:
    @bounded(0, 100)
    def level(self) -> float:
        ...

For production, prefer explicit properties — the decorator factory is interview-clever, not always maintainable.


@property vs @classmethod / @staticmethod#

Tool Purpose
@property Attribute-like access with logic
@classmethod Alternative constructors, class-level factories
@staticmethod Utility grouped in class namespace
class User:
    def __init__(self, email: str):
        self._email = email

    @property
    def email(self) -> str:
        return self._email

    @classmethod
    def from_dict(cls, data: dict) -> "User":
        return cls(data["email"])

    @staticmethod
    def is_valid_email(s: str) -> bool:
        return "@" in s

See Class Methods and Static Methods.


Interview traps (quick reference)#

Trap What goes wrong Safe approach
Property without setter AttributeError on assign Add setter or use public field
Setter without backing field Infinite recursion (self.x = x) Store in self._x
Returning mutable internal state Caller mutates internals Return .copy() or immutable view
Heavy work in property getter Hidden performance cost cached_property or explicit method
Over-using properties Verbose, un-Pythonic Public attrs when no invariant
@property on __init__ assignment before _field exists AttributeError Set self._field in __init__, or order setters carefully

Mental model checklist#

  1. What is the descriptor protocol, and where does @property fit?
  2. When is a read-only property appropriate vs a regular method?
  3. Why store backing data in _name instead of name inside the getter?
  4. How does cached_property differ from a manual cache in a getter?
  5. When should you not use @property?

What's next#

Topic Page
Encapsulation conventions Encapsulation and Access Modifiers
Decorator mechanics Decorators
ABC + abstract properties Abstract Base Classes
Intermediate OOP Classes and OOP Basics