Skip to main content

Python Property Decorators

In the world of Python object-oriented programming, property decorators stand out as an elegant way to manage access to class attributes. They allow you to add behavior when getting, setting, or deleting attributes, all while maintaining a clean and intuitive syntax.

Introduction to Property Decorators

When building classes in Python, you often need to control how attributes are accessed and modified. Instead of using explicit getter and setter methods as in other languages, Python offers property decorators that let you achieve the same functionality with a more Pythonic approach.

Property decorators allow you to:

  • Control access to class attributes
  • Validate data before assigning to attributes
  • Compute attribute values on the fly
  • Implement "read-only" attributes
  • Add side effects when attributes are modified

The property Decorator Basics

At its core, the property decorator transforms a method into an attribute-like accessor. Let's start with a simple example:

python
class Person:
def __init__(self, name):
self._name = name

@property
def name(self):
return self._name

# Using the property
person = Person("Alice")
print(person.name) # Accesses the property like an attribute
Output:
Alice

In this example, name becomes a "getter" property that provides controlled access to the _name attribute. Note the following:

  1. The _name attribute is prefixed with an underscore to indicate it's intended for internal use
  2. The @property decorator transforms the name() method into a getter
  3. We access person.name without parentheses, as if it were a regular attribute

Adding Setter and Deleter Methods

Properties become more powerful when adding setter and deleter methods:

python
class Person:
def __init__(self, name):
self._name = name

@property
def name(self):
"""Get the person's name."""
return self._name

@name.setter
def name(self, value):
"""Set the person's name with validation."""
if not isinstance(value, str):
raise TypeError("Name must be a string")
if len(value) < 2:
raise ValueError("Name is too short")
self._name = value

@name.deleter
def name(self):
"""Delete the person's name."""
print(f"Deleting name: {self._name}")
self._name = None

# Using the property
person = Person("Bob")
print(person.name) # Using the getter

person.name = "Charlie" # Using the setter
print(person.name)

try:
person.name = 123 # Will raise TypeError
except TypeError as e:
print(f"Error: {e}")

del person.name # Using the deleter
print(person.name) # Now returns None
Output:
Bob
Charlie
Error: Name must be a string
Deleting name: Charlie
None

Here's what's happening:

  1. @property defines the getter method
  2. @name.setter defines how the attribute is set, with validation
  3. @name.deleter defines what happens when the attribute is deleted
  4. We access, set, and delete the property using natural attribute syntax

Practical Applications of Property Decorators

Example 1: Temperature Converter

Let's create a class that converts between Celsius and Fahrenheit:

python
class Temperature:
def __init__(self, celsius=0):
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 is not possible")
self._celsius = value

@property
def fahrenheit(self):
# Convert from celsius to fahrenheit
return self._celsius * 9/5 + 32

@fahrenheit.setter
def fahrenheit(self, value):
# Convert from fahrenheit to celsius
self.celsius = (value - 32) * 5/9

# Using the temperature class
temp = Temperature(25)
print(f"Celsius: {temp.celsius}°C")
print(f"Fahrenheit: {temp.fahrenheit}°F")

temp.fahrenheit = 68
print(f"Celsius: {temp.celsius}°C")

try:
temp.celsius = -300 # Below absolute zero
except ValueError as e:
print(f"Error: {e}")
Output:
Celsius: 25°C
Fahrenheit: 77.0°F
Celsius: 20.0°C
Error: Temperature below absolute zero is not possible

In this example, the properties provide:

  1. Data validation (preventing temperatures below absolute zero)
  2. Automatic conversion between units
  3. A clean interface for users of the class

Example 2: Banking Account with Transaction Logging

Let's implement a bank account class that logs changes to the balance:

python
from datetime import datetime

class BankAccount:
def __init__(self, account_number, initial_balance=0):
self._account_number = account_number
self._balance = initial_balance
self._transactions = []

if initial_balance > 0:
self._log_transaction("Initial deposit", initial_balance)

def _log_transaction(self, transaction_type, amount):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._transactions.append({
"timestamp": timestamp,
"type": transaction_type,
"amount": amount,
"balance": self._balance
})

@property
def account_number(self):
"""Get the account number (read-only property)"""
return self._account_number

@property
def balance(self):
"""Get current balance"""
return self._balance

@balance.setter
def balance(self, value):
"""Setting balance directly is not allowed"""
raise AttributeError("Cannot set balance directly. Use deposit() or withdraw() methods.")

def deposit(self, amount):
"""Deposit money into the account"""
if amount <= 0:
raise ValueError("Deposit amount must be positive")

self._balance += amount
self._log_transaction("Deposit", amount)
return self._balance

def withdraw(self, amount):
"""Withdraw money from the account"""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")

self._balance -= amount
self._log_transaction("Withdrawal", -amount)
return self._balance

@property
def transaction_history(self):
"""Get a copy of the transaction history"""
return self._transactions.copy()

# Using the bank account
account = BankAccount("12345", 1000)
print(f"Account: {account.account_number}, Balance: ${account.balance}")

account.deposit(500)
account.withdraw(200)

try:
account.balance = 5000 # This will raise an error
except AttributeError as e:
print(f"Error: {e}")

print(f"Final balance: ${account.balance}")

# Print transaction history
print("\nTransaction History:")
for transaction in account.transaction_history:
print(f"{transaction['timestamp']} - {transaction['type']}: ${abs(transaction['amount'])} - Balance: ${transaction['balance']}")
Output:
Account: 12345, Balance: $1000
Error: Cannot set balance directly. Use deposit() or withdraw() methods.
Final balance: $1300

Transaction History:
2023-10-15 14:32:10 - Initial deposit: $1000 - Balance: $1000
2023-10-15 14:32:10 - Deposit: $500 - Balance: $1500
2023-10-15 14:32:10 - Withdrawal: $200 - Balance: $1300

In this example, properties help:

  1. Make account_number read-only
  2. Prevent direct modification of the balance attribute
  3. Force users to use the deposit() and withdraw() methods
  4. Provide a safe way to access transaction history

Property Decorator vs. Property Function

There are two ways to define properties in Python:

Using the Property Decorator (modern approach)

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

@property
def radius(self):
return self._radius

@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value

@property
def area(self):
import math
return math.pi * self._radius ** 2

Using the Property Function (classic approach)

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

def get_radius(self):
return self._radius

def set_radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value

def get_area(self):
import math
return math.pi * self._radius ** 2

radius = property(get_radius, set_radius)
area = property(get_area)

Both achieve the same result, but the decorator syntax is more readable and widely used in modern Python code.

Best Practices for Using Properties

  1. Use properties for computed attributes: If an attribute value depends on other attributes or involves calculation, make it a property.

  2. Add validation in setters: Use setter methods to ensure attribute values meet requirements.

  3. Follow naming conventions: Use a single underscore prefix (_attribute_name) for the internal attribute to indicate it's intended for private use.

  4. Keep property methods simple: If complex logic is needed, consider moving it to separate methods.

  5. Consider read-only properties: For attributes that shouldn't be changed directly, omit the setter.

  6. Document your properties: Use docstrings to explain what properties represent and any validation rules.

Summary

Property decorators provide a clean, Pythonic way to implement getters, setters, and deleters for class attributes. They enable data validation, computed attributes, and attribute access control without sacrificing Python's elegant syntax.

Key points to remember:

  • @property creates getter methods that are accessed like attributes
  • @attribute.setter defines how attributes are set
  • @attribute.deleter defines the behavior when deleting attributes
  • Properties help maintain encapsulation while preserving intuitive syntax

Exercises

  1. Create a Rectangle class with properties for width and height that validate the values are positive. Add a property called area that calculates the rectangle's area.

  2. Extend the Person class to include properties for age with validation (must be between 0 and 120) and full_name that combines first and last name properties.

  3. Create a File class that opens a file when initialized, provides properties to access file content, and automatically closes the file when the object is deleted.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)