Property
Property is a powerful Python feature that allows methods to be accessed as if they were attributes. It enables clean implementation of data validation, computed attributes, and access control. It is the most Pythonic way to achieve encapsulation in Python OOP.
The @property Decorator: getter
The @property decorator turns a method into a read-only attribute. No parentheses are needed to call it.
class Circle:
def __init__(self, radius: float):
self._radius = radius # Actual data is stored in _radius
@property
def radius(self) -> float:
"""getter for the radius attribute"""
return self._radius
@property
def area(self) -> float:
"""Computed property — read-only, no setter"""
import math
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
"""Circumference"""
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5 — it's a method, but accessed like an attribute
print(c.area) # 78.539...
print(c.circumference) # 31.415...
# Read-only without a setter
try:
c.area = 100 # AttributeError!
except AttributeError as e:
print(f"Error: {e}") # can't set attribute
@prop.setter: setter
Using @property.setter allows writing to the property while adding validation logic.
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # Initialize via setter (includes validation)
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError(f"Temperature below absolute zero ({value}°C) is impossible.")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Celsius ↔ Fahrenheit — supports both read and write"""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.celsius = (value - 32) * 5/9 # Validates through the celsius setter
@property
def kelvin(self) -> float:
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value: float) -> None:
self.celsius = value - 273.15
def __repr__(self) -> str:
return f"Temperature({self._celsius}°C)"
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
print(t.kelvin) # 373.15
# Set via Fahrenheit
t.fahrenheit = 32
print(t.celsius) # 0.0
# Set via Kelvin
t.kelvin = 0
print(t.celsius) # -273.15
# Validation check
try:
t.celsius = -300 # ValueError!
except ValueError as e:
print(e)
@prop.deleter: deleter
@property.deleter handles the del obj.prop syntax.
class UserProfile:
def __init__(self, username: str, email: str):
self.username = username
self._email = email
self._phone: str | None = None
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
if "@" not in value:
raise ValueError(f"Invalid email: {value}")
self._email = value.lower().strip()
@email.deleter
def email(self) -> None:
print(f"Deleting {self.username}'s email.")
self._email = ""
@property
def phone(self) -> str | None:
return self._phone
@phone.setter
def phone(self, value: str | None) -> None:
if value is not None:
# Normalize phone number (extract digits only)
digits = "".join(c for c in value if c.isdigit())
if len(digits) not in (10, 11):
raise ValueError(f"Invalid phone number: {value}")
self._phone = digits
else:
self._phone = None
@phone.deleter
def phone(self) -> None:
self._phone = None
user = UserProfile("john", "JOHN@EXAMPLE.COM")
print(user.email) # john@example.com (normalized to lowercase)
user.phone = "010-1234-5678"
print(user.phone) # 01012345678 (digits only)
del user.email # Deleting john's email.
print(user.email) # ""
Validation Pattern
class Person:
def __init__(self, name: str, age: int, email: str):
# Consistently apply validation through setters
self.name = name
self.age = age
self.email = email
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("Name cannot be empty.")
if len(value) > 50:
raise ValueError("Name must be 50 characters or fewer.")
self._name = value
@property
def age(self) -> int:
return self._age
@age.setter
def age(self, value: int) -> None:
if not isinstance(value, int):
raise TypeError(f"Age must be an integer. Received: {type(value)}")
if not (0 <= value <= 150):
raise ValueError(f"Invalid age: {value}")
self._age = value
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
import re
value = value.strip().lower()
if not re.match(r"[^@]+@[^@]+\.[^@]+", value):
raise ValueError(f"Invalid email: {value}")
self._email = value
@property
def is_adult(self) -> bool:
"""Computed read-only property"""
return self._age >= 18
def __repr__(self) -> str:
return f"Person(name={self._name!r}, age={self._age}, email={self._email!r})"
p = Person("Alice", 25, "alice@example.com")
print(p)
print(p.is_adult) # True
# Validation in action
try:
p.age = -1
except ValueError as e:
print(f"Error: {e}")
try:
p.email = "invalid-email"
except ValueError as e:
print(f"Error: {e}")
Computed Properties
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
if value <= 0:
raise ValueError(f"Width must be positive: {value}")
self._width = value
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, value: float) -> None:
if value <= 0:
raise ValueError(f"Height must be positive: {value}")
self._height = value
@property
def area(self) -> float:
"""Computed property — recalculated whenever width or height changes"""
return self._width * self._height
@property
def perimeter(self) -> float:
return 2 * (self._width + self._height)
@property
def diagonal(self) -> float:
return (self._width ** 2 + self._height ** 2) ** 0.5
@property
def is_square(self) -> bool:
return self._width == self._height
def __repr__(self) -> str:
return f"Rectangle({self._width} × {self._height})"
r = Rectangle(4, 3)
print(f"Area: {r.area}") # 12.0
print(f"Perimeter: {r.perimeter}") # 14.0
print(f"Diagonal: {r.diagonal}") # 5.0
print(f"Square? {r.is_square}") # False
r.width = 5
print(f"New area: {r.area}") # 15.0 (automatically recomputed)
Cached Property (functools.cached_property)
Python 3.8+ provides functools.cached_property to compute expensive calculations once and cache the result.
from functools import cached_property
import time
class DataAnalyzer:
def __init__(self, data: list[float]):
self._data = data
@cached_property
def mean(self) -> float:
"""Computed only on first access, then cached"""
print("Computing mean...")
time.sleep(0.1) # Simulate an expensive computation
return sum(self._data) / len(self._data)
@cached_property
def variance(self) -> float:
print("Computing variance...")
mean = self.mean # Already cached
return sum((x - mean) ** 2 for x in self._data) / len(self._data)
@cached_property
def std_dev(self) -> float:
return self.variance ** 0.5
@property
def count(self) -> int:
"""Simple computation — caching not needed"""
return len(self._data)
data = DataAnalyzer([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])
print(data.mean) # "Computing mean..." then 5.0
print(data.mean) # Cached — no output, returns instantly
print(data.variance) # "Computing variance..." then result
print(data.std_dev) # variance already cached
property vs __getattr__ / __setattr__
class FlexibleConfig:
"""Dynamic attribute access using __getattr__"""
def __init__(self, **kwargs):
self._data: dict = kwargs
def __getattr__(self, name: str):
"""Called when an undefined attribute is accessed"""
if name.startswith("_"):
raise AttributeError(name)
if name in self._data:
return self._data[name]
raise AttributeError(f"No config setting: {name}")
def __setattr__(self, name: str, value) -> None:
"""Called for every attribute assignment"""
if name.startswith("_"):
super().__setattr__(name, value) # Normal handling for internal attributes
else:
self._data[name] = value # Store external attributes in _data
def __delattr__(self, name: str) -> None:
if name in self._data:
del self._data[name]
else:
super().__delattr__(name)
config = FlexibleConfig(host="localhost", port=8080, debug=True)
print(config.host) # localhost
print(config.port) # 8080
config.database = "mydb" # Dynamically add an attribute
print(config.database) # mydb
del config.debug
try:
print(config.debug) # AttributeError
except AttributeError as e:
print(f"Error: {e}")
Practical Example: Temperature Converter
class TemperatureConverter:
"""A class supporting multiple temperature units"""
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float = 0.0):
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < self.ABSOLUTE_ZERO_C:
raise ValueError(
f"Temperature cannot be below absolute zero ({self.ABSOLUTE_ZERO_C}°C). "
f"Got: {value}"
)
self._celsius = float(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
@property
def kelvin(self) -> float:
return self._celsius - self.ABSOLUTE_ZERO_C
@kelvin.setter
def kelvin(self, value: float) -> None:
if value < 0:
raise ValueError(f"Kelvin temperature must be non-negative: {value}")
self.celsius = value + self.ABSOLUTE_ZERO_C
@property
def rankine(self) -> float:
"""Rankine temperature (thermodynamic unit)"""
return self.fahrenheit + 459.67
@property
def description(self) -> str:
"""A description of the current temperature"""
c = self._celsius
if c < -10:
return "Extremely cold"
elif c < 10:
return "Cold"
elif c < 20:
return "Cool"
elif c < 30:
return "Warm"
else:
return "Hot"
def __str__(self) -> str:
return (f"{self._celsius}°C / {self.fahrenheit:.1f}°F / "
f"{self.kelvin:.2f}K — {self.description}")
def __repr__(self) -> str:
return f"TemperatureConverter(celsius={self._celsius})"
# Usage example
t = TemperatureConverter(25)
print(t) # 25.0°C / 77.0°F / 298.15K — Warm
t.fahrenheit = 32
print(t.celsius) # 0.0
t.kelvin = 373.15
print(t.celsius) # 100.00000000000003 (floating-point error)
print(TemperatureConverter(-40)) # -40°C / -40.0°F / ... (-40 is the same in both)
Practical Example: Age Validation Class
from datetime import date
class PersonWithAge:
"""A class with age validation and computed properties"""
def __init__(self, name: str, birth_date: date):
self.name = name
self.birth_date = birth_date
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("Name cannot be empty.")
self._name = value
@property
def birth_date(self) -> date:
return self._birth_date
@birth_date.setter
def birth_date(self, value: date) -> None:
if not isinstance(value, date):
raise TypeError("birth_date must be a date object.")
if value > date.today():
raise ValueError("Birth date cannot be in the future.")
if value.year < 1900:
raise ValueError("Birth dates before 1900 are not supported.")
self._birth_date = value
@property
def age(self) -> int:
"""Calculate age in completed years"""
today = date.today()
years = today.year - self._birth_date.year
if (today.month, today.day) < (self._birth_date.month, self._birth_date.day):
years -= 1
return years
@property
def is_adult(self) -> bool:
return self.age >= 18
@property
def is_senior(self) -> bool:
return self.age >= 65
@property
def birth_year(self) -> int:
return self._birth_date.year
@property
def zodiac_sign(self) -> str:
"""Western zodiac sign"""
month = self._birth_date.month
day = self._birth_date.day
signs = [
(1, 20, "Capricorn"), (2, 19, "Aquarius"), (3, 20, "Pisces"),
(4, 20, "Aries"), (5, 21, "Taurus"), (6, 21, "Gemini"),
(7, 23, "Cancer"), (8, 23, "Leo"), (9, 23, "Virgo"),
(10, 23, "Libra"), (11, 22, "Scorpio"), (12, 22, "Sagittarius"),
(12, 31, "Capricorn"),
]
for m, d, sign in signs:
if month < m or (month == m and day <= d):
return sign
return "Capricorn"
def __str__(self) -> str:
status = "Adult" if self.is_adult else "Minor"
return f"{self._name} ({self.age} years old, {status})"
p = PersonWithAge("Alice", date(1998, 7, 15))
print(p) # Alice (27 years old, Adult)
print(p.age) # 27
print(p.is_adult) # True
print(p.zodiac_sign) # Cancer
# Validation
try:
p.birth_date = date(2030, 1, 1) # Future date
except ValueError as e:
print(f"Error: {e}")
Expert Tips
1. Using property() directly (the old-style approach)
# Using property() directly — the old way (for understanding only)
class OldStyle:
def __init__(self, value):
self._value = value
def _get_value(self):
return self._value
def _set_value(self, v):
self._value = v
value = property(_get_value, _set_value)
# The @property approach is exactly equivalent (recommended)
class NewStyle:
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
@value.setter
def value(self, v):
self._value = v
2. Reusable properties via the Descriptor Protocol
class PositiveNumber:
"""A reusable descriptor that validates positive numbers"""
def __set_name__(self, owner, name):
self._name = name
self._private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._private_name, 0)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self._name} must be a number.")
if value <= 0:
raise ValueError(f"{self._name} must be positive: {value}")
setattr(obj, self._private_name, value)
class Product:
price = PositiveNumber() # Reused
quantity = PositiveNumber() # Reused
weight = PositiveNumber() # Reused
def __init__(self, price: float, quantity: int, weight: float):
self.price = price
self.quantity = quantity
self.weight = weight
p = Product(9.90, 10, 0.5)
print(p.price, p.quantity, p.weight)
try:
p.price = -100 # ValueError: price must be positive
except ValueError as e:
print(e)
3. Apply validation via setter in __init__
class SafePoint:
def __init__(self, x: float, y: float):
# Don't set _x, _y directly — use setters for validation
self.x = x # → calls @x.setter → validation runs
self.y = y # → calls @y.setter → validation runs
@property
def x(self) -> float:
return self._x
@x.setter
def x(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError("x must be a number.")
self._x = float(value)
@property
def y(self) -> float:
return self._y
@y.setter
def y(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError("y must be a number.")
self._y = float(value)
Summary
| Approach | When to Use |
|---|---|
@property (getter only) | Read-only computed attribute |
@prop.setter | Validation needed on write |
@prop.deleter | Cleanup needed on deletion |
cached_property | Expensive computation, computed only once |
__getattr__ | Dynamic attributes, handle access to nonexistent attributes |
__setattr__ | Intercept all attribute assignments |
Property is a core tool in Python OOP. Start with a plain instance variable, and if you later need validation, wrap it with @property — the external API stays the same.