Previous | Tutorial index | Next

πŸ“˜ TUTORIAL 11: THE @property DECORATOR SYNTAX

Learning Objective

Use the @property decorator as the preferred, Pythonic way to create managed attributes with explicit getter, setter, and deleter.

11.1 Introduction to the @property Decorator

In 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.

Why Use the Decorator Syntax?

Comparison

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.

11.2 How the @property Decorator Works

When you write:

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

Python does the following:

Then, when you write:

@name.setter def name(self, value): self._name = value

Python:

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.

11.3 Defining a Read-Only Property

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.

11.4 Defining a Property with Setter and Deleter

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.

11.5 Adding Validation and Type Checking

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.

11.6 Computed Properties (Read-Only)

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.

11.7 Caching Computed Properties

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.

11.8 Properties with Inheritance

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

11.9 Advanced: Dynamic Properties with Descriptors

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.

11.10 When to Use @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

11.11 Common Pitfalls and Best Practices

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.

11.12 Full Walkthrough Example: A User Class with Email Validation

import 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

πŸ“ Quiz 11: The @property Decorator

Answer the following questions to check your understanding.

1. Which decorator is used to define the getter for a property?

Answer(B) `@property`

2. How do you define a setter for a property named age?

Answer(A) `@age.setter`

3. If you define a property with a getter but no setter, what happens when you try to assign a value to it?

Answer(B) An `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?

Answer(A) `5`

5. (True/False) The docstring of a property is taken from the getter method's docstring.

Answer(A) True

6. What is the recommended way to implement a computed attribute that is derived from other attributes?

Answer(B) Use a read‑only property with `@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()
Answer(B) The name in uppercase because the setter converts it to uppercase when assigned in `__init__`.

8. If you override only the getter of a property in a subclass, what happens to the setter from the parent?

Answer(B) It is lost; you need to redefine it if you want a setter.

9. Which of the following is NOT a valid use case for a property?

Answer(C) Storing a constant that never changes – a constant should be a class attribute or a plain attribute, not a property.

10. What is the purpose of @name.deleter?

Answer(A) To define a method that is called when the attribute is deleted.
Below is the revised **Exercise 11** and **Homework 11** text with sample answers added. Each sample implementation and reflection is provided inside a `
` block so that students can review them after attempting the tasks on their own.

πŸ§ͺ Exercise 11: Building a Class with Properties

Part A: Create a Student Class
Define a Student class with the following requirements:

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?

Sample Answers (Exercise 11)

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.

🏠 Homework 11: Building an Employee Management System with Properties

Task: Create a complete Employee class that uses the @property decorator to manage all attributes with strict validation and computed properties.

Class Specifications

Private Attributes:

Properties:

  1. full_name – read-only computed property that returns "First Last".

  2. first_name – getter and setter.

    • Setter: non-empty string, capitalize it.
  3. last_name – getter and setter.

    • Setter: non-empty string, capitalize it.
  4. salary – getter and setter.

    • Setter: must be a positive float or int. If ≀ 0, raise ValueError.
  5. department – getter and setter.

    • Setter: non-empty string, strip whitespace.
  6. hire_date – getter and setter.

    • Setter: must be a tuple of three ints (year, month, day). Validate year β‰₯ 2000, month 1-12, day 1-31 (simplified).
    • Provide a read-only property years_employed that computes years since hire (use current year = 2026 for simplicity).
  7. performance_rating – getter and setter.

    • Setter: float between 0 and 5 inclusive.
  8. monthly_salary – read-only property returning salary / 12.

  9. bonus – read-only property returning salary * (performance_rating / 100).

Methods:

Sample Answers (Homework 11)

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

Homework Submission Requirements

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:

  1. Create at least three employees with valid data.
  2. Print each employee using print().
  3. Demonstrate validation by attempting to create an employee with invalid data (e.g., negative salary, empty name, invalid date) and catch the exceptions.
  4. For one employee, change the salary and performance rating, then show the updated monthly salary and bonus.
  5. Compute and print the number of years employed for each employee.
  6. Store employees in a list and compute the total annual salary expenditure.

Part 3: Reflection Questions
Answer these in a comment block at the top of your script:

  1. Why is full_name a read-only property rather than a method? What are the advantages of making it a property?
  2. Why did we use properties for all attributes instead of direct attribute access? How does this improve the class's robustness?
  3. What would happen if we changed the internal representation of hire_date from a tuple to a datetime.date? How would that affect external code that uses the property?
  4. The 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?
  5. In the __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.

πŸ“š Additional Resources for Self-Study

  1. Real Python: Python's property(): Add Managed Attributes to Your Classes – Comprehensive guide with decorator examples.
  2. ZetCode: Python @property decorator – Focus on decorator syntax.
  3. Python Official Docs: property – The built-in reference.
  4. Compile-N-Run: Python Property Decorators – Examples and patterns.

βœ… Summary Checklist for Tutorial 11

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:

You are now well-equipped to write robust, Pythonic object-oriented code. Good luck with your future programming endeavors!

Previous | Tutorial index | Next