Menu

Python @property: Getters, Setters, and Attribute Control Guide

Join thousands of students who advanced their careers with MachineLearningPlus. Go from Beginner to Data Science (AI/ML/Gen AI) Expert through a structured pathway of 9 core specializations and build industry grade projects.

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:

  1. Use one canonical storage format (Celsius) and convert others
  2. Validate in the primary setter and reuse for conversions
  3. Provide meaningful computed properties (description)
  4. Allow setting via different units while maintaining consistency

Scroll to Top
Course Preview

Machine Learning A-Z™: Hands-On Python & R In Data Science

Free Sample Videos:

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Scroll to Top