Python - Property Decorator (Getters/Setters)
The property decorator converts class methods into 'managed attributes' that execute code when accessed, modified, or deleted. Unlike traditional getter/setter methods that require explicit method...
Key Insights
- Property decorators transform methods into managed attributes, eliminating boilerplate getter/setter code while maintaining encapsulation and validation logic
- The
@propertydecorator enables computed attributes, lazy loading, and backward-compatible API changes without breaking existing code that accesses attributes directly - Properties support getter, setter, and deleter methods through decorator chaining, providing fine-grained control over attribute access patterns
Understanding the Property Decorator
The property decorator converts class methods into “managed attributes” that execute code when accessed, modified, or deleted. Unlike traditional getter/setter methods that require explicit method calls, properties maintain the clean syntax of direct attribute access while executing custom logic behind the scenes.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@celsius.deleter
def celsius(self):
print("Deleting temperature value")
del self._celsius
# Usage
temp = Temperature(25)
print(temp.celsius) # 25 (calls getter)
temp.celsius = 30 # calls setter with validation
del temp.celsius # calls deleter
The underscore prefix _celsius follows Python convention for internal attributes, signaling that direct access should be avoided in favor of the property interface.
Computed Properties and Derived Values
Properties excel at calculating values on-the-fly without storing redundant data. This pattern reduces memory overhead and eliminates synchronization issues between related attributes.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
@property
def perimeter(self):
return 2 * (self.width + self.height)
@property
def diagonal(self):
return (self.width ** 2 + self.height ** 2) ** 0.5
rect = Rectangle(5, 3)
print(rect.area) # 15
print(rect.perimeter) # 16
print(rect.diagonal) # 5.830951894845301
rect.width = 10
print(rect.area) # 30 (automatically reflects new width)
Computed properties always return current values based on the underlying data, preventing stale cached values that plague manually updated attributes.
Validation and Type Checking
Properties provide centralized validation logic that executes automatically on every assignment, ensuring data integrity throughout the object’s lifecycle.
class User:
def __init__(self, username, email, age):
self.username = username
self.email = email
self.age = age
@property
def username(self):
return self._username
@username.setter
def username(self, value):
if not isinstance(value, str):
raise TypeError("Username must be a string")
if len(value) < 3:
raise ValueError("Username must be at least 3 characters")
self._username = value.lower()
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if '@' not in value or '.' not in value.split('@')[1]:
raise ValueError("Invalid email format")
self._email = value.lower()
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise TypeError("Age must be an integer")
if value < 0 or value > 150:
raise ValueError("Age must be between 0 and 150")
self._age = value
# Validation happens automatically
user = User("JohnDoe", "john@example.com", 25)
user.age = 30 # Valid
user.age = -5 # Raises ValueError
user.email = "invalid" # Raises ValueError
Lazy Loading and Expensive Operations
Properties enable lazy initialization, deferring expensive operations until the attribute is actually accessed. This optimization improves startup time and reduces unnecessary resource consumption.
import json
import time
class DataProcessor:
def __init__(self, filepath):
self.filepath = filepath
self._data = None
self._processed_data = None
@property
def data(self):
if self._data is None:
print(f"Loading data from {self.filepath}")
time.sleep(1) # Simulate expensive I/O
with open(self.filepath, 'r') as f:
self._data = json.load(f)
return self._data
@property
def processed_data(self):
if self._processed_data is None:
print("Processing data...")
time.sleep(2) # Simulate expensive computation
self._processed_data = [
item['value'] * 2
for item in self.data
]
return self._processed_data
# Data loads only when accessed
processor = DataProcessor('data.json')
print("Processor created")
# ... other operations ...
result = processor.processed_data # Loads and processes here
Read-Only Properties
Omitting the setter decorator creates read-only properties, preventing external modification while allowing internal updates through the private attribute.
from datetime import datetime
class Transaction:
def __init__(self, amount, description):
self._amount = amount
self._description = description
self._timestamp = datetime.now()
self._id = id(self)
@property
def amount(self):
return self._amount
@property
def description(self):
return self._description
@property
def timestamp(self):
return self._timestamp
@property
def transaction_id(self):
return f"TXN-{self._id}"
transaction = Transaction(100.50, "Payment")
print(transaction.amount) # 100.5
print(transaction.transaction_id) # TXN-140234567890123
# These raise AttributeError
transaction.amount = 200
transaction.transaction_id = "NEW-ID"
Backward Compatibility and API Evolution
Properties allow refactoring internal implementations without breaking existing code that accesses attributes directly. This enables smooth API evolution.
# Version 1: Simple attribute
class Product:
def __init__(self, price):
self.price = price
# Version 2: Add currency conversion without breaking existing code
class Product:
def __init__(self, price, currency='USD'):
self._price_usd = price if currency == 'USD' else price * 0.85
self._currency = currency
@property
def price(self):
# External code still accesses .price, but now it's computed
if self._currency == 'EUR':
return self._price_usd * 1.18
return self._price_usd
@price.setter
def price(self, value):
self._price_usd = value
self._currency = 'USD'
# Existing code continues working
product = Product(100)
print(product.price) # Still works
product.price = 150 # Still works
Property with Custom Descriptors
For reusable validation logic across multiple classes, combine properties with descriptor protocol implementation.
class ValidatedProperty:
def __init__(self, validator):
self.validator = validator
self.name = None
def __set_name__(self, owner, name):
self.name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name)
def __set__(self, obj, value):
self.validator(value)
setattr(obj, self.name, value)
def positive_number(value):
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError("Must be a positive number")
def non_empty_string(value):
if not isinstance(value, str) or not value.strip():
raise ValueError("Must be a non-empty string")
class Product:
price = ValidatedProperty(positive_number)
name = ValidatedProperty(non_empty_string)
def __init__(self, name, price):
self.name = name
self.price = price
product = Product("Laptop", 999.99)
product.price = 1299.99 # Valid
product.price = -50 # Raises ValueError
product.name = "" # Raises ValueError
Performance Considerations
Properties add minimal overhead compared to direct attribute access, but repeated access in tight loops can accumulate costs. Cache computed values when appropriate.
class OptimizedCircle:
def __init__(self, radius):
self._radius = radius
self._area_cache = None
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
self._area_cache = None # Invalidate cache
@property
def area(self):
if self._area_cache is None:
self._area_cache = 3.14159 * self._radius ** 2
return self._area_cache
circle = OptimizedCircle(5)
# Multiple accesses use cached value
for _ in range(1000):
_ = circle.area # Fast: uses cache
Properties represent a fundamental Python idiom for building robust, maintainable classes. They bridge the gap between simple attribute access and complex behavior, enabling clean APIs that evolve gracefully over time.