Previous | Tutorial index | Next
@property DECORATOR SYNTAXUse the @property decorator as the preferred, Pythonic way to create managed attributes with explicit getter, setter, and deleter.
@property DecoratorIn Tutorial 10, we used the property() function to create managed attributes. While functional, the syntax can be a bit verbose and spread out: you define separate getter, setter, and deleter functions, then call property() to combine them.
Python offers a more elegant decorator-based syntax that is now the standard way to define properties. The @property decorator allows you to define a method that acts as a getter, and then use @<property_name>.setter and @<property_name>.deleter to attach setter and deleter methods.
Functional style (from Tutorial 10):
class Person:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
if not value:
raise ValueError("Name cannot be empty")
self._name = value
name = property(get_name, set_name, doc="The person's name.")
Decorator style:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
"""The person's name."""
return self._name
@name.setter
def name(self, value):
if not value:
raise ValueError("Name cannot be empty")
self._name = value
Notice how the decorator style is more compact and the relationship between getter, setter, and deleter is clearer.
@property Decorator WorksWhen you write:
@property
def name(self):
return self._name
Python does the following:
property object with the fget parameter set to the method name.name.Then, when you write:
@name.setter
def name(self, value):
self._name = value
Python:
name)..setter() method, which returns a new property object with the setter added.name with this new property object.The same applies to .deleter().
Key point: The methods you decorate must have the same name as the property (name in this case). This is how the decorators know which property to attach to.
If you only need a getter, omit the setter and deleter. The property will be read-only.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@property
def area(self):
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.5398...
# c.radius = 10 # AttributeError: can't set attribute
# c.area = 20 # AttributeError: can't set attribute
Note that area has only a getter, so it's also read-only. It's a computed property.
To add a setter, use @<property_name>.setter. To add a deleter, use @<property_name>.deleter.
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
"""The person's name."""
return self._name
@name.setter
def name(self, value):
if not value:
raise ValueError("Name cannot be empty")
self._name = value
@name.deleter
def name(self):
print(f"Deleting name '{self._name}'...")
del self._name
p = Person("Alice")
print(p.name) # Alice
p.name = "Bob"
print(p.name) # Bob
del p.name # Deleting name 'Bob'...
# print(p.name) # AttributeError: 'Person' object has no attribute '_name'
The docstring on the getter automatically becomes the docstring for the property. If you add a docstring to the setter or deleter, it will be ignored.
One of the most common uses of properties is to validate data before storing it.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Balance must be a number")
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance = self.balance + amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance = self.balance - amount
acc = BankAccount("Alice", 100)
print(acc.balance) # 100
acc.balance = 150 # OK
# acc.balance = -10 # ValueError
# acc.balance = "hi" # TypeError
Important: Inside deposit, we used self.balance = self.balance + amount. This calls the getter and the setter, so validation is applied. However, be careful: if you use self._balance += amount, you bypass the setter and its validation. Which is better depends on your design; using self.balance = ... ensures validation is always enforced.
Properties are excellent for attributes that are derived from other data.
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)
r = Rectangle(3, 4)
print(r.area) # 12
print(r.perimeter) # 14
r.width = 5
print(r.area) # 20 (recomputed)
Because area and perimeter are properties, they look like attributes but are computed each time.
If a computed property is expensive to calculate, you might want to cache the result after the first computation. This is often done with a _cached_<name> attribute.
class DataProcessor:
def __init__(self, data):
self._data = data
self._cached_result = None
@property
def processed(self):
if self._cached_result is None:
print("Computing processed result...")
# Simulate expensive computation
self._cached_result = sum(self._data) * 2
return self._cached_result
@processed.setter
def processed(self, value):
# If someone sets a value, invalidate cache?
raise AttributeError("processed is read-only")
dp = DataProcessor([1, 2, 3, 4])
print(dp.processed) # Computing... 20
print(dp.processed) # 20 (cached)
If the underlying data changes, you'd need to reset _cached_result = None. You can add a setter for the data to invalidate the cache.
Properties are inherited and can be overridden in subclasses, just like methods.
class Parent:
@property
def name(self):
return "Parent"
class Child(Parent):
@property
def name(self):
return "Child"
c = Child()
print(c.name) # Child
You can also override only part of a property (e.g., the getter but keep the setter). However, if you override the getter, the setter from the parent is not automatically inherited; you need to re-apply it.
class Parent:
def __init__(self):
self._name = "Parent"
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
class Child(Parent):
@property
def name(self):
return f"Child says {self._name}"
# We don't define a setter, so the property is read-only in Child
# To keep the setter, we need to redefine it:
@name.setter
def name(self, value):
self._name = value
c = Child()
c.name = "Bob"
print(c.name) # Child says Bob
Properties are a special case of Python's descriptor protocol. A property is a descriptor that stores getter, setter, and deleter functions. You can create your own descriptors, but @property covers most use cases.
@property vs. Plain Attributes| Scenario | Use |
|---|---|
| Simple data storage with no validation | Plain attribute (self.x = value) |
| Data that needs validation, type checking, or computation | Property |
| Read-only attribute (derived or immutable) | Property with only getter |
| You might change internal storage later | Property (to maintain API) |
| You want to deprecate an old attribute | Property with a warning in the getter/setter |
| Pitfall | Explanation | How to Avoid |
|---|---|---|
Using self.attr inside the getter/setter |
If getter returns self.name (instead of self._name), it will call itself infinitely. |
Always use the backing store (e.g., self._name) inside getter/setter. |
| Forgetting to define setter when needed | If you define a getter but later want to add a setter, you can't just add @<name>.setter after the fact? Actually you can, but you need the property object to exist. You can define a new setter decorator as long as the property exists. |
It's fine to add later; the @<name>.setter decorator will find the existing property. |
Using properties in __init__ |
If you use self.name = value in __init__, the setter will be called, which is fine and ensures validation. But if you assign to self._name directly, you bypass validation. |
Decide whether validation should happen during initialization. Usually, it's better to use the property to validate initial data. |
| Overriding only getter in subclass | If you override the getter, the setter from parent is lost unless you re-declare it. | If you need to keep the setter, you must re-apply @<name>.setter in the subclass. |
| Making a property with expensive computation | If the computation is heavy and the property is accessed many times, it can be slow. | Consider caching the result or making it a method if it's not meant to be attribute-like. |
| Using property for side effects | Properties should not have side effects (like logging is fine, but changing state beyond the attribute itself is questionable). | Keep properties focused on the attribute; use methods for actions. |
User Class with Email Validationimport re
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 value or not value.isalnum():
raise ValueError("Username must be alphanumeric and non-empty")
self._username = value
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if not re.match(r"[^@]+@[^@]+\.[^@]+", value):
raise ValueError("Invalid email format")
self._email = value
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or value < 0 or value > 150:
raise ValueError("Age must be an integer between 0 and 150")
self._age = value
@property
def email_domain(self):
"""Read-only computed property: extract domain from email."""
return self.email.split('@')[1]
# Test
u = User("alice123", "alice@example.com", 30)
print(u.username) # alice123
print(u.email) # alice@example.com
print(u.age) # 30
print(u.email_domain) # example.com
# u.username = "bob!" # ValueError
# u.email = "bad" # ValueError
# u.age = -5 # ValueError
@property DecoratorAnswer the following questions to check your understanding.
1. Which decorator is used to define the getter for a property?
@getter@property@property.getter@getter_method2. How do you define a setter for a property named age?
@age.setter@age.set@setter(age)@property.setter(age)3. If you define a property with a getter but no setter, what happens when you try to assign a value to it?
AttributeError is raised.4. Consider the following code:
class Test:
def __init__(self, x):
self._x = x
@property
def x(self):
return self._x
@x.setter
def x(self, value):
if value < 0:
raise ValueError("Negative not allowed")
self._x = value
What will Test(5).x return?
5_xAttributeError5. (True/False) The docstring of a property is taken from the getter method's docstring.
6. What is the recommended way to implement a computed attribute that is derived from other attributes?
get_area().@property.7. In the code below, what will print(p.name) output?
class Person:
def __init__(self, name):
self.name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value.upper()
AttributeError.None.8. If you override only the getter of a property in a subclass, what happens to the setter from the parent?
9. Which of the following is NOT a valid use case for a property?
10. What is the purpose of @name.deleter?
None.Part A: Create a Student Class
Define a Student class with the following requirements:
_name, _grade (float, 0-100), _courses (list of strings).name β getter and setter: name must be non-empty string.grade β getter and setter: grade must be between 0 and 100 inclusive.courses β getter only (return a copy of the list to prevent external modification).letter_grade β read-only computed property that returns 'A', 'B', 'C', 'D', 'F' based on grade (90-100 A, 80-89 B, etc.).add_course(course_name) to add a course, has_course(course_name) to check if a course is in the list.Part B: Test the Class
Create a student and test all properties and methods, including edge cases (empty name, invalid grade, adding duplicate courses, etc.).
Part C: Reflection
In comments, explain why courses is a read-only property that returns a copy of the list rather than the list itself. What would happen if we returned the list directly?
Part A: Student Class Implementation
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
self._courses = []
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value or not value.strip():
raise ValueError("Name cannot be empty")
self._name = value.strip()
@property
def grade(self):
return self._grade
@grade.setter
def grade(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Grade must be a number")
if not (0 <= value <= 100):
raise ValueError("Grade must be between 0 and 100")
self._grade = float(value)
@property
def courses(self):
# Return a copy to prevent external modification
return self._courses.copy()
@property
def letter_grade(self):
g = self.grade
if g >= 90:
return 'A'
elif g >= 80:
return 'B'
elif g >= 70:
return 'C'
elif g >= 60:
return 'D'
else:
return 'F'
def add_course(self, course_name):
if not course_name or not course_name.strip():
raise ValueError("Course name cannot be empty")
course = course_name.strip()
if course not in self._courses:
self._courses.append(course)
else:
print(f"Course '{course}' already added.")
def has_course(self, course_name):
return course_name in self._courses
Part B: Testing
# Create a student
s = Student("Alice", 85)
print(s.name) # Alice
print(s.grade) # 85.0
print(s.letter_grade) # B
# Add courses
s.add_course("Math")
s.add_course("Science")
s.add_course("Math") # Duplicate: prints message
print(s.courses) # ['Math', 'Science'] (a copy)
# Test validation
try:
s.name = ""
except ValueError as e:
print(e) # Name cannot be empty
try:
s.grade = 110
except ValueError as e:
print(e) # Grade must be between 0 and 100
# Test has_course
print(s.has_course("Math")) # True
print(s.has_course("History")) # False
Part C: Reflection Answer
# Why courses returns a copy:
# Returning a copy prevents external code from modifying the internal list
# directly (e.g., appending, removing, or clearing courses). If we returned
# the list itself, external code could bypass the add_course() logic and
# potentially add duplicate courses or invalid entries, breaking the
# class's invariants. The copy ensures that the class retains full control
# over its course data.
Task: Create a complete Employee class that uses the @property decorator to manage all attributes with strict validation and computed properties.
Private Attributes:
_first_name (str)_last_name (str)_salary (float) β annual salary, must be > 0._department (str)_hire_date β a tuple (year, month, day). For simplicity, use a tuple of ints._performance_rating β float between 0 and 5 (default 3.0).Properties:
full_name β read-only computed property that returns "First Last".
first_name β getter and setter.
last_name β getter and setter.
salary β getter and setter.
ValueError.department β getter and setter.
hire_date β getter and setter.
years_employed that computes years since hire (use current year = 2026 for simplicity).performance_rating β getter and setter.
monthly_salary β read-only property returning salary / 12.
bonus β read-only property returning salary * (performance_rating / 100).
Methods:
__init__(first_name, last_name, salary, department, hire_date, performance_rating=3.0) β initialize using properties to enforce validation.__str__ β returns a nicely formatted string with all details.Complete Implementation of Employee
class Employee:
def __init__(self, first_name, last_name, salary, department, hire_date, performance_rating=3.0):
self.first_name = first_name
self.last_name = last_name
self.salary = salary
self.department = department
self.hire_date = hire_date
self.performance_rating = performance_rating
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def first_name(self):
return self._first_name
@first_name.setter
def first_name(self, value):
if not value or not value.strip():
raise ValueError("First name cannot be empty")
self._first_name = value.strip().capitalize()
@property
def last_name(self):
return self._last_name
@last_name.setter
def last_name(self, value):
if not value or not value.strip():
raise ValueError("Last name cannot be empty")
self._last_name = value.strip().capitalize()
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Salary must be a number")
if value <= 0:
raise ValueError("Salary must be positive")
self._salary = float(value)
@property
def department(self):
return self._department
@department.setter
def department(self, value):
if not value or not value.strip():
raise ValueError("Department cannot be empty")
self._department = value.strip()
@property
def hire_date(self):
return self._hire_date
@hire_date.setter
def hire_date(self, value):
if not isinstance(value, tuple) or len(value) != 3:
raise ValueError("Hire date must be a tuple of (year, month, day)")
year, month, day = value
if not (isinstance(year, int) and isinstance(month, int) and isinstance(day, int)):
raise ValueError("Year, month, and day must be integers")
if year < 2000:
raise ValueError("Year must be >= 2000")
if not (1 <= month <= 12):
raise ValueError("Month must be between 1 and 12")
if not (1 <= day <= 31):
raise ValueError("Day must be between 1 and 31")
self._hire_date = (year, month, day)
@property
def years_employed(self):
# Using current year 2026 for simplicity
return 2026 - self.hire_date[0]
@property
def performance_rating(self):
return self._performance_rating
@performance_rating.setter
def performance_rating(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Performance rating must be a number")
if not (0 <= value <= 5):
raise ValueError("Performance rating must be between 0 and 5")
self._performance_rating = float(value)
@property
def monthly_salary(self):
return self.salary / 12
@property
def bonus(self):
return self.salary * (self.performance_rating / 100)
def __str__(self):
return (f"Employee: {self.full_name}\n"
f" Department: {self.department}\n"
f" Salary: ${self.salary:,.2f} per year (${self.monthly_salary:,.2f} monthly)\n"
f" Hire Date: {self.hire_date[0]}-{self.hire_date[1]:02d}-{self.hire_date[2]:02d}\n"
f" Years Employed: {self.years_employed}\n"
f" Performance Rating: {self.performance_rating:.1f}/5.0\n"
f" Bonus: ${self.bonus:,.2f}")
Testing Script
if __name__ == "__main__":
# Create valid employees
emp1 = Employee("alice", "johnson", 75000, "Engineering", (2020, 6, 15), 4.5)
emp2 = Employee("bob", "smith", 60000, "Marketing", (2022, 3, 1), 3.2)
emp3 = Employee("carol", "white", 90000, "Sales", (2018, 9, 10), 4.8)
print(emp1)
print(emp2)
print(emp3)
# Test validation
try:
emp_bad = Employee("", "Doe", 50000, "IT", (2023, 1, 1))
except ValueError as e:
print(f"Caught: {e}")
try:
emp_bad = Employee("John", "Doe", -1000, "IT", (2023, 1, 1))
except ValueError as e:
print(f"Caught: {e}")
try:
emp_bad = Employee("John", "Doe", 50000, "IT", (1999, 1, 1))
except ValueError as e:
print(f"Caught: {e}")
# Modify and see updates
emp1.performance_rating = 4.9
print(f"{emp1.full_name}'s updated bonus: ${emp1.bonus:,.2f}")
emp1.salary = 80000
print(emp1.monthly_salary) # updated automatically
Part 1: Code
Write the complete Employee class with all properties and methods. Use the @property decorator for all properties. Include docstrings and comments.
Part 2: Testing Script
Create a test function that does the following:
print().Part 3: Reflection Questions
Answer these in a comment block at the top of your script:
full_name a read-only property rather than a method? What are the advantages of making it a property?hire_date from a tuple to a datetime.date? How would that affect external code that uses the property?bonus property uses performance_rating. If we changed the rating scale from 0-5 to 0-10, where would we need to update the class? How do properties help localize such changes?__init__ method, we used the properties (e.g., self.first_name = first_name) rather than directly assigning to _first_name. Why is this beneficial?Bonus Challenge (Optional):
Add a class attribute employee_count that increments when a new employee is created. Add a class method get_average_salary(employees) that computes the average salary of a list of employees. Add a static method is_valid_date(year, month, day) for date validation, and use it in the hire_date setter.
Before concluding Unit 8, ensure you can confidently say YES to the following:
Congratulations! You have now completed all tutorials for Unit 8. You have learned:
__init__ (Tutorial 2)super() (Tutorial 4)property() function (Tutorial 10)@property decorator (Tutorial 11)You are now well-equipped to write robust, Pythonic object-oriented code. Good luck with your future programming endeavors!