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 @property decorator 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.