@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)
- What
@propertyactually does - Three legitimate use cases
- Read-only properties
@propertyvs public attributes — when to use whichproperty()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
- Property inheritance and overriding
- Descriptor protocol — how lookup interacts with
@property - Common patterns in interviews
@propertyvs@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!
__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#
- What is the descriptor protocol, and where does
@propertyfit? - When is a read-only property appropriate vs a regular method?
- Why store backing data in
_nameinstead ofnameinside the getter? - How does
cached_propertydiffer from a manual cache in a getter? - 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 |