Python @property: Getters, Setters, and Attribute Control Guide
Learn to use Python's @property decorator to implement getters and setters for safe attribute access and data validation in OOP.
By Abhay Palakkode · July 2025
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.
PyInteractive Python
classUser:def__init__(self, email, age):self.email = emailself.age = agedefcan_vote(self):returnself.age >=18# The problem: No validationuser = User("alice@example.com", 25)print(f"Can vote: {user.can_vote()}") # True# This should not be allowed!user.email ="invalid-email"# Invalid email formatuser.age =-5# Negative age makes no senseprint(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.
PyInteractive Python
classUser:def__init__(self, email, age):self._email = emailself._age = agedefget_email(self):returnself._emaildefset_email(self, value):if'@'notin value:raiseValueError("Invalid email format")self._email = valuedefget_age(self):returnself._agedefset_age(self, value):if value <0:raiseValueError("Age cannot be negative")if value >150:raiseValueError("Age cannot exceed 150")self._age = valuedefcan_vote(self):returnself._age >=18# Validation works, but syntax is clunkyuser = User("alice@example.com", 25)print(f"Email: {user.get_email()}") # alice@example.comuser.set_age(30) # Validprint(f"New age: {user.get_age()}") # 30# This correctly raises an errortry: user.set_email("invalid-email")exceptValueErroras e:print(f"Error: {e}")#> Email: alice@example.com#> 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.
PyInteractive Python
classUser:def__init__(self, email, age):self._email =""self._age =0self.email = email # Use setter for validationself.age = age # Use setter for validation@propertydefemail(self):"""Getter for email"""returnself._email@email.setterdefemail(self, value):"""Setter with validation for email"""ifnotisinstance(value, str):raiseTypeError("Email must be a string")if'@'notin value:raiseValueError("Invalid email format")self._email = value.lower() # Normalize to lowercase@propertydefage(self):"""Getter for age"""returnself._age@age.setterdefage(self, value):"""Setter with validation for age"""ifnotisinstance(value, int):raiseTypeError("Age must be an integer")if value <0:raiseValueError("Age cannot be negative")if value >150:raiseValueError("Age cannot exceed 150")self._age = value@propertydefcan_vote(self):"""Computed read-only property"""returnself._age >=18@propertydefstatus(self):"""Computed property for user status"""ifself._age <13:return"Child"elifself._age <18:return"Teen"elifself._age <65:return"Adult"else:return"Senior"# Perfect: Elegant syntax with validationuser = User("Alice@Example.com", 25)print(f"Email: {user.email}") # alice@example.com (normalized)print(f"Can vote: {user.can_vote}") # True (computed property)print(f"Status: {user.status}") # Adultuser.age =30# Uses setter with validationprint(f"New age: {user.age}") # 30# Validation still works with elegant syntaxtry: user.email ="invalid-email"# Raises ValueErrorexceptValueErroras e:print(f"Error: {e}")#> Email: alice@example.com#> 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.
classServer:
def__init__(self, host, port):
self._host =""self._port =0self.host = host # Use setter for validationself.port = port # Use setter for validation@propertydefhost(self):
"""Get server host"""returnself._host
@host.setter
defhost(self, value):
"""Set host with validation"""ifnotisinstance(value, str) ornot value.strip():
raiseValueError("Host must be a non-empty string")
self._host = value.strip().lower()
@propertydefport(self):
"""Get server port"""returnself._port
@port.setter
defport(self, value):
"""Set port with validation"""ifnotisinstance(value, int):
raiseTypeError("Port must be an integer")
ifnot (1<= value <=65535):
raiseValueError("Port must be between 1 and 65535")
self._port = value
@propertydefbase_url(self):
"""Computed property: full server URL"""
protocol ="https"ifself._port ==443else"http"returnf"{protocol}://{self._host}:{self._port}"@propertydefapi_endpoint(self):
"""Computed property: API endpoint URL"""returnf"{self.base_url}/api/v1"@propertydefconnection_string(self):
"""Computed property: connection details"""returnf"server={self._host};port={self._port}"# All properties update automatically
server = Server("api.example.com", 8080)
print(f"Host: {server.host}") # api.example.comprint(f"Base URL: {server.base_url}") # http://api.example.com:8080print(f"API Endpoint: {server.api_endpoint}") # http://api.example.com:8080/api/v1# Change port - everything updates automatically
server.port =443print(f"New Base URL: {server.base_url}") # https://api.example.com:443print(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.
PyInteractive Python
classUser:def__init__(self, name, email=None):self.name = nameself._email =Noneself._phone =Noneif email:self.email = email@propertydefemail(self):"""Get email address"""returnself._email@email.setterdefemail(self, value):"""Set email with validation"""if value and'@'notin value:raiseValueError("Invalid email format")self._email = value@email.deleterdefemail(self):"""Delete email (reset to None)"""print("Email deleted")self._email =None@propertydefphone(self):"""Get phone number"""returnself._phone@phone.setterdefphone(self, value):"""Set phone with basic validation"""if value andnotstr(value).replace('-', '').replace(' ', '').isdigit():raiseValueError("Phone must contain only digits, spaces, and dashes")self._phone = value@phone.deleterdefphone(self):"""Delete phone number"""print("Phone number deleted")self._phone =None@propertydefcontact_info(self):"""Read-only summary of contact information""" info = [self.name]ifself._email: info.append(f"Email: {self._email}")ifself._phone: info.append(f"Phone: {self._phone}")return" | ".join(info)# Using deletersuser = User("Alice", "alice@example.com")user.phone ="555-1234"print(user.contact_info) # Alice | Email: alice@example.com | Phone: 555-1234# Delete propertiesdel user.email # Prints: "Email deleted"del user.phone # Prints: "Phone number deleted"print(user.contact_info) # Alice#> Alice | Email: alice@example.com | 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.
PyInteractive Python
classProduct:def__init__(self, name, price, discount_percent=0):self._name =""self._price =0self._discount_percent =0# Use setters for validationself.name = nameself.price = priceself.discount_percent = discount_percent@propertydefname(self):returnself._name@name.setterdefname(self, value):ifnot value ornot value.strip():raiseValueError("Product name cannot be empty")self._name = value.strip().title() # Clean and format@propertydefprice(self):returnself._price@price.setterdefprice(self, value):ifnotisinstance(value, (int, float)):raiseTypeError("Price must be a number")if value <0:raiseValueError("Price cannot be negative")self._price =float(value)@propertydefdiscount_percent(self):returnself._discount_percent@discount_percent.setterdefdiscount_percent(self, value):ifnotisinstance(value, (int, float)):raiseTypeError("Discount must be a number")ifnot (0<= value <=100):raiseValueError("Discount must be between 0 and 100")self._discount_percent = value@propertydefsale_price(self):"""Computed property: price after discount"""returnself._price * (1-self._discount_percent /100)@propertydefsavings(self):"""Computed property: amount saved"""returnself._price -self.sale_price# Real-world usageproduct = Product("wireless headphones", 199.99, 15)print(f"Product: {product.name}") # Wireless Headphones (auto-formatted)print(f"Original: ${product.price:.2f}") # $199.99print(f"Sale: ${product.sale_price:.2f}") # $169.99print(f"You save: ${product.savings:.2f}") # $30.00# Validation in actionproduct.discount_percent =25print(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.
PyInteractive Python
classTemperature:"""Best practices for property implementation"""def__init__(self, celsius=0):self._celsius =0self.celsius = celsius # Use setter for validation@propertydefcelsius(self):"""Primary temperature storage in Celsius"""returnself._celsius@celsius.setterdefcelsius(self, value):"""Validate temperature is above absolute zero"""ifnotisinstance(value, (int, float)):raiseTypeError("Temperature must be a number")if value <-273.15: # Absolute zero in CelsiusraiseValueError("Temperature cannot be below absolute zero (-273.15°C)")self._celsius = value@propertydeffahrenheit(self):"""Computed property: Celsius to Fahrenheit"""return (self._celsius *9/5) +32@fahrenheit.setterdeffahrenheit(self, value):"""Set temperature via Fahrenheit (converts to Celsius)"""ifnotisinstance(value, (int, float)):raiseTypeError("Temperature must be a number") celsius_value = (value -32) *5/9self.celsius = celsius_value # Use Celsius setter for validation@propertydefkelvin(self):"""Computed property: Celsius to Kelvin"""returnself._celsius +273.15@kelvin.setterdefkelvin(self, value):"""Set temperature via Kelvin (converts to Celsius)"""ifnotisinstance(value, (int, float)):raiseTypeError("Temperature must be a number") celsius_value = value -273.15self.celsius = celsius_value # Use Celsius setter for validation@propertydefdescription(self):"""Read-only descriptive property"""ifself._celsius <0:return"Freezing"elifself._celsius <20:return"Cold"elifself._celsius <30:return"Comfortable"else:return"Hot"# Best practices in actiontemp = Temperature(25) # 25°Cprint(f"Temperature: {temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")print(f"Feels: {temp.description}")# Set via different units - all stay in synctemp.fahrenheit =100# Sets to 37.78°Cprint(f"New temp: {temp.celsius:.1f}°C ({temp.description})")temp.kelvin =300# Sets to 26.85°Cprint(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