Property decorators in Python allow you to combine the elegance of dot notation with the power of validation and control. Properties let you access methods like attributes while maintaining control over how data is accessed, set, and validated, giving you both the simplicity of direct attribute access and the safety of explicit getter and setter methods.
If you’ve ever wanted to use simple dot notation (like obj.value = 10) while still having validation and control over your data, properties are your solution. They solve the fundamental tension between code elegance and data safety, letting you write clean, Pythonic code without sacrificing robustness.
The @property decorator was introduced as part of Python’s descriptor protocol, designed by Guido van Rossum to provide controlled attribute access. This feature allows developers to start with simple attributes and later add validation or computation without changing the interface that users of the class expect.
1. Understanding the Problem with Direct Attribute Access
Before diving into properties, let’s understand why we need them. Direct attribute access is elegant but lacks control and validation.
Let’s create a User class with email and age attributes that can be set directly, demonstrating how this approach lacks validation and can lead to invalid user data.
class User:
def __init__(self, email, age):
self.email = email
self.age = age
def can_vote(self):
return self.age >= 18
# The problem: No validation
user = User("[email protected]", 25)
print(f"Can vote: {user.can_vote()}") # True
# This should not be allowed!
user.email = "invalid-email" # Invalid email format
user.age = -5 # Negative age makes no sense
print(f"Invalid user can vote: {user.can_vote()}") # False (wrong for wrong reasons)
Can vote: True
Invalid user can vote: False
This shows the problem: direct attribute access is clean and Pythonic, but we can’t prevent invalid values. Users can set malformed emails or impossible ages, creating invalid user objects.
2. Traditional Solution: Explicit Getter and Setter Methods
The traditional approach uses explicit methods for getting and setting values, but loses the elegance of dot notation.
Consider the User class with private attributes _email and _age, plus explicit methods get_email(), set_email(), etc., showing how this approach provides validation but sacrifices elegance.
class User:
def __init__(self, email, age):
self._email = email
self._age = age
def get_email(self):
return self._email
def set_email(self, value):
if '@' not in value:
raise ValueError("Invalid email format")
self._email = value
def get_age(self):
return self._age
def set_age(self, value):
if value < 0:
raise ValueError("Age cannot be negative")
if value > 150:
raise ValueError("Age cannot exceed 150")
self._age = value
def can_vote(self):
return self._age >= 18
# Validation works, but syntax is clunky
user = User("[email protected]", 25)
print(f"Email: {user.get_email()}") # [email protected]
user.set_age(30) # Valid
print(f"New age: {user.get_age()}") # 30
# This correctly raises an error
try:
user.set_email("invalid-email")
except ValueError as e:
print(f"Error: {e}")
Email: [email protected]
New age: 30
Error: Invalid email format
This approach provides validation but requires verbose method calls instead of elegant dot notation. Users must remember to call get_age() and set_age() instead of simply accessing age.
3. The Property Solution: Dot Notation with Validation
Properties combine the best of both worlds – elegant dot notation with full validation and control. We’ll create a class with private attributes _email and _age, then use @property decorators to create getters and @attribute.setter decorators for validation, achieving both elegance and safety.
class User:
def __init__(self, email, age):
self._email = ""
self._age = 0
self.email = email # Use setter for validation
self.age = age # Use setter for validation
@property
def email(self):
"""Getter for email"""
return self._email
@email.setter
def email(self, value):
"""Setter with validation for email"""
if not isinstance(value, str):
raise TypeError("Email must be a string")
if '@' not in value:
raise ValueError("Invalid email format")
self._email = value.lower() # Normalize to lowercase
@property
def age(self):
"""Getter for age"""
return self._age
@age.setter
def age(self, value):
"""Setter with validation for age"""
if not isinstance(value, int):
raise TypeError("Age must be an integer")
if value < 0:
raise ValueError("Age cannot be negative")
if value > 150:
raise ValueError("Age cannot exceed 150")
self._age = value
@property
def can_vote(self):
"""Computed read-only property"""
return self._age >= 18
@property
def status(self):
"""Computed property for user status"""
if self._age < 13:
return "Child"
elif self._age < 18:
return "Teen"
elif self._age < 65:
return "Adult"
else:
return "Senior"
# Perfect: Elegant syntax with validation
user = User("[email protected]", 25)
print(f"Email: {user.email}") # [email protected] (normalized)
print(f"Can vote: {user.can_vote}") # True (computed property)
print(f"Status: {user.status}") # Adult
user.age = 30 # Uses setter with validation
print(f"New age: {user.age}") # 30
# Validation still works with elegant syntax
try:
user.email = "invalid-email" # Raises ValueError
except ValueError as e:
print(f"Error: {e}")
Email: [email protected]
Can vote: True
Status: Adult
New age: 30
Error: Invalid email format
Now we have both elegant dot notation (user.age = 30) and complete validation. Users can’t tell the difference between a simple attribute and a property – the interface is identical.
4. Advanced Property Patterns: Computed and Dependent Properties
Properties can also compute values dynamically and create dependencies between attributes. Consider a class with a _host attribute and computed properties for base_url, api_endpoint, and connection_string that automatically update when host or port changes, plus validation to ensure valid network configurations.
class Server:
def __init__(self, host, port):
self._host = ""
self._port = 0
self.host = host # Use setter for validation
self.port = port # Use setter for validation
@property
def host(self):
"""Get server host"""
return self._host
@host.setter
def host(self, value):
"""Set host with validation"""
if not isinstance(value, str) or not value.strip():
raise ValueError("Host must be a non-empty string")
self._host = value.strip().lower()
@property
def port(self):
"""Get server port"""
return self._port
@port.setter
def port(self, value):
"""Set port with validation"""
if not isinstance(value, int):
raise TypeError("Port must be an integer")
if not (1 <= value <= 65535):
raise ValueError("Port must be between 1 and 65535")
self._port = value
@property
def base_url(self):
"""Computed property: full server URL"""
protocol = "https" if self._port == 443 else "http"
return f"{protocol}://{self._host}:{self._port}"
@property
def api_endpoint(self):
"""Computed property: API endpoint URL"""
return f"{self.base_url}/api/v1"
@property
def connection_string(self):
"""Computed property: connection details"""
return f"server={self._host};port={self._port}"
# All properties update automatically
server = Server("api.example.com", 8080)
print(f"Host: {server.host}") # api.example.com
print(f"Base URL: {server.base_url}") # http://api.example.com:8080
print(f"API Endpoint: {server.api_endpoint}") # http://api.example.com:8080/api/v1
# Change port - everything updates automatically
server.port = 443
print(f"New Base URL: {server.base_url}") # https://api.example.com:443
print(f"New API Endpoint: {server.api_endpoint}") # https://api.example.com:443/api/v1
Host: api.example.com
Base URL: http://api.example.com:8080
API Endpoint: http://api.example.com:8080/api/v1
New Base URL: https://api.example.com:443
New API Endpoint: https://api.example.com:443/api/v1
Computed properties automatically stay in sync. When port changes to 443, the base_url automatically switches to HTTPS protocol, and api_endpoint reflects the new URL without any additional code.
5. Property Deleters and Full Control
Properties can also control deletion of attributes using the @property.deleter decorator.
Let’s create a User class with _email and _phone attributes that can be safely deleted and reset to default values, demonstrating complete property control including deletion.
class User:
def __init__(self, name, email=None):
self.name = name
self._email = None
self._phone = None
if email:
self.email = email
@property
def email(self):
"""Get email address"""
return self._email
@email.setter
def email(self, value):
"""Set email with validation"""
if value and '@' not in value:
raise ValueError("Invalid email format")
self._email = value
@email.deleter
def email(self):
"""Delete email (reset to None)"""
print("Email deleted")
self._email = None
@property
def phone(self):
"""Get phone number"""
return self._phone
@phone.setter
def phone(self, value):
"""Set phone with basic validation"""
if value and not str(value).replace('-', '').replace(' ', '').isdigit():
raise ValueError("Phone must contain only digits, spaces, and dashes")
self._phone = value
@phone.deleter
def phone(self):
"""Delete phone number"""
print("Phone number deleted")
self._phone = None
@property
def contact_info(self):
"""Read-only summary of contact information"""
info = [self.name]
if self._email:
info.append(f"Email: {self._email}")
if self._phone:
info.append(f"Phone: {self._phone}")
return " | ".join(info)
# Using deleters
user = User("Alice", "[email protected]")
user.phone = "555-1234"
print(user.contact_info) # Alice | Email: [email protected] | Phone: 555-1234
# Delete properties
del user.email # Prints: "Email deleted"
del user.phone # Prints: "Phone number deleted"
print(user.contact_info) # Alice
Alice | Email: [email protected] | Phone: 555-1234
Email deleted
Phone number deleted
Alice
Property deleters give you complete control over attribute lifecycle, allowing custom cleanup logic when attributes are deleted.
6. Real-world Use Case: Data Validation and Transformation
Properties are perfect for data classes that need validation, transformation, or computed fields.
We’ll create a Product class with _name, _price, _discount_percent attributes, using properties to validate prices are positive, names are non-empty, discounts are valid percentages, and automatically compute sale prices.
class Product:
def __init__(self, name, price, discount_percent=0):
self._name = ""
self._price = 0
self._discount_percent = 0
# Use setters for validation
self.name = name
self.price = price
self.discount_percent = discount_percent
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value or not value.strip():
raise ValueError("Product name cannot be empty")
self._name = value.strip().title() # Clean and format
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Price must be a number")
if value < 0:
raise ValueError("Price cannot be negative")
self._price = float(value)
@property
def discount_percent(self):
return self._discount_percent
@discount_percent.setter
def discount_percent(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Discount must be a number")
if not (0 <= value <= 100):
raise ValueError("Discount must be between 0 and 100")
self._discount_percent = value
@property
def sale_price(self):
"""Computed property: price after discount"""
return self._price * (1 - self._discount_percent / 100)
@property
def savings(self):
"""Computed property: amount saved"""
return self._price - self.sale_price
# Real-world usage
product = Product("wireless headphones", 199.99, 15)
print(f"Product: {product.name}") # Wireless Headphones (auto-formatted)
print(f"Original: ${product.price:.2f}") # $199.99
print(f"Sale: ${product.sale_price:.2f}") # $169.99
print(f"You save: ${product.savings:.2f}") # $30.00
# Validation in action
product.discount_percent = 25
print(f"New sale price: ${product.sale_price:.2f}") # $149.99
Product: Wireless Headphones
Original: $199.99
Sale: $169.99
You save: $30.00
New sale price: $149.99
This demonstrates how properties handle real-world requirements: data validation, automatic formatting, computed values, and maintaining data consistency.
7. Best Practices for Property Usage
Here are the key patterns for using properties effectively in production code.
We’ll create a class that stores temperature in Celsius but provides properties for Fahrenheit and Kelvin conversion, showing how to handle computed properties, validation, and read-only properties properly.
class Temperature:
"""Best practices for property implementation"""
def __init__(self, celsius=0):
self._celsius = 0
self.celsius = celsius # Use setter for validation
@property
def celsius(self):
"""Primary temperature storage in Celsius"""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Validate temperature is above absolute zero"""
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be a number")
if value < -273.15: # Absolute zero in Celsius
raise ValueError("Temperature cannot be below absolute zero (-273.15°C)")
self._celsius = value
@property
def fahrenheit(self):
"""Computed property: Celsius to Fahrenheit"""
return (self._celsius * 9/5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Set temperature via Fahrenheit (converts to Celsius)"""
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be a number")
celsius_value = (value - 32) * 5/9
self.celsius = celsius_value # Use Celsius setter for validation
@property
def kelvin(self):
"""Computed property: Celsius to Kelvin"""
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value):
"""Set temperature via Kelvin (converts to Celsius)"""
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be a number")
celsius_value = value - 273.15
self.celsius = celsius_value # Use Celsius setter for validation
@property
def description(self):
"""Read-only descriptive property"""
if self._celsius < 0:
return "Freezing"
elif self._celsius < 20:
return "Cold"
elif self._celsius < 30:
return "Comfortable"
else:
return "Hot"
# Best practices in action
temp = Temperature(25) # 25°C
print(f"Temperature: {temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")
print(f"Feels: {temp.description}")
# Set via different units - all stay in sync
temp.fahrenheit = 100 # Sets to 37.78°C
print(f"New temp: {temp.celsius:.1f}°C ({temp.description})")
temp.kelvin = 300 # Sets to 26.85°C
print(f"Via Kelvin: {temp.celsius:.1f}°C")
Temperature: 25°C = 77.0°F = 298.15K
Feels: Comfortable
New temp: 37.8°C (Hot)
Via Kelvin: 26.9°C
Key best practices demonstrated:
- Use one canonical storage format (Celsius) and convert others
- Validate in the primary setter and reuse for conversions
- Provide meaningful computed properties (description)
- Allow setting via different units while maintaining consistency



