Python properties provide a powerful way to control how attributes of an object are accessed and modified. They are especially useful when we want an attribute to behave like a normal variable while internally using methods to control its value.
In a simple Python class, we usually access attributes directly:
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
student = Student("Rahul", 21)
print(student.name)
print(student.age)
This is simple and convenient. However, direct access can become a problem when we need validation, calculated values, or additional logic whenever an attribute is read or changed.
For example, suppose the age of a student should never be negative.
student.age = -10
With direct attribute access, Python does not automatically know that this value should be rejected.
We could create a method such as:
student.set_age(-10)
but this changes the way we interact with the attribute.
Python properties provide a cleaner solution:
student.age = 21
while allowing us to execute validation logic internally.
A property is an object attribute that is controlled by methods, allowing us to execute custom logic when the attribute is accessed, changed, or deleted.
The most common way to create a property is by using the:
@property
decorator.
For example:
class Student:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
Now create an object:
student = Student("Rahul")
We can access:
print(student.name)
Output:
Rahul
Notice that we did not write:
student.name()
We simply wrote:
student.name
Even though internally Python is calling a method.
This is one of the most useful features of the @property decorator.
Consider:
class Student:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
The method:
def name(self):
is converted into a property by:
@property
Therefore, when we write:
student.name
Python executes the property method behind the scenes.
Conceptually:
student.name
↓
@property
↓
name()
↓
self._name
This gives us the convenience of attribute access while allowing us to execute method logic internally.
Properties are useful when an attribute needs additional logic.
Common use cases include:
For example, imagine an employee’s salary.
Direct access would allow:
employee.salary = -50000
which may be invalid.
A property can prevent invalid values.
Suppose we create a normal method:
class Student:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
We must call:
student.get_name()
With a property:
class Student:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
we can write:
student.name
This makes the attribute interface cleaner.
| Normal Method | Property |
|---|---|
student.get_name() |
student.name |
| Explicit method call | Attribute-style access |
| Clearly behaves like a method | Behaves like an attribute to the caller |
| Useful for actions | Useful for controlled attribute access |
You will often see a property implemented with an underscore-prefixed attribute:
self._name
For example:
class Employee:
def __init__(self, salary):
self._salary = salary
@property
def salary(self):
return self._salary
Here, _salary is commonly treated as an internal attribute by convention.
The public interface is:
employee.salary
while the internal storage is:
employee._salary
The leading underscore is a naming convention indicating that the attribute is intended for internal use.
One useful application of @property is creating a read-only attribute.
For example:
class Employee:
def __init__(self, employee_id):
self._employee_id = employee_id
@property
def employee_id(self):
return self._employee_id
Now:
employee = Employee("EMP101")
print(employee.employee_id)
works normally.
But there is no setter defined for employee_id.
Therefore, assigning:
employee.employee_id = "EMP202"
will raise an error because the property has no setter.
This is useful when a value should be exposed for reading but should not normally be changed through the public interface.
Properties are also useful when a value should be calculated dynamically.
Suppose an employee has:
basic_salary
bonus
Instead of storing total salary separately, we can calculate it through a property.
class Employee:
def __init__(
self,
basic_salary,
bonus
):
self.basic_salary = basic_salary
self.bonus = bonus
@property
def total_salary(self):
return (
self.basic_salary
+ self.bonus
)
Now:
employee = Employee(
50000,
5000
)
We can simply write:
print(employee.total_salary)
Output:
55000
There is no need to store total_salary separately.
If the basic salary changes:
employee.basic_salary = 60000
then:
print(employee.total_salary)
automatically reflects the new calculation.
This is an important property use case.
Properties can be very useful when creating custom Data Analytics classes.
Suppose we create a dataset object:
class Dataset:
def __init__(
self,
name,
rows,
columns
):
self.name = name
self.rows = rows
self.columns = columns
@property
def size(self):
return self.rows * self.columns
Create:
dataset = Dataset(
"Sales Data",
5000,
12
)
Now:
print(dataset.size)
Output:
60000
The size value is calculated dynamically.
If the number of rows changes:
dataset.rows = 6000
then:
print(dataset.size)
automatically returns:
72000
This is cleaner than manually updating a separate size attribute.
Properties are closely related to encapsulation.
Suppose we have an internal value:
self._salary
Instead of allowing uncontrolled access, we can expose it through:
@property
def salary(self):
return self._salary
Now the class controls how the value is accessed.
Conceptually:
External Code
↓
salary property
↓
_internal value
This creates a controlled interface between the outside code and the internal state of the object.
Properties can also be useful when an existing class initially exposes a simple attribute but later needs additional logic.
Suppose the original class has:
employee.salary
Later, you realize salary needs validation.
Changing all external code to:
employee.get_salary()
would be inconvenient.
A property allows you to keep the same external syntax:
employee.salary
while introducing internal logic.
This is one reason properties are considered an important Python design feature.
The basic read-only property pattern is:
class Example:
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
The external code uses:
obj.value
rather than:
obj.value()
The property provides attribute-style access while executing method logic internally.
@property decorator creates a property._salary.In the next section, we will learn how to create setters and deleters using @property, @attribute.setter, and @attribute.deleter, including validation examples.
Python properties become especially valuable when a class needs to control, validate, calculate, or transform its attributes. Instead of exposing internal data directly, a property can provide a clean public interface while keeping the implementation inside the class.
This is particularly useful in real-world applications such as employee management systems, banking applications, e-commerce systems, and Data Analytics projects.
A calculated property is a value that is derived from other attributes rather than stored separately.
Consider an employee:
class Employee:
def __init__(self, basic_salary, bonus):
self.basic_salary = basic_salary
self.bonus = bonus
@property
def total_salary(self):
return (
self.basic_salary
+ self.bonus
)
Now:
employee = Employee(
50000,
5000
)
print(employee.total_salary)
Output:
55000
There is no separate total_salary variable. The property calculates the value whenever it is requested.
If the bonus changes:
employee.bonus = 10000
print(employee.total_salary)
the result automatically changes:
60000
This is useful because the calculated value always reflects the current state of the object.
Calculated properties are very useful when working with datasets.
class Dataset:
def __init__(
self,
rows,
columns
):
self.rows = rows
self.columns = columns
@property
def total_cells(self):
return self.rows * self.columns
Create a dataset:
dataset = Dataset(
5000,
12
)
Now:
print(dataset.total_cells)
Output:
60000
If the dataset grows:
dataset.rows = 6000
then:
print(dataset.total_cells)
returns:
72000
The calculation does not need to be manually updated.
Suppose a dataset contains the number of successful and total records.
class DataQuality:
def __init__(
self,
total,
valid
):
self.total = total
self.valid = valid
@property
def valid_percentage(self):
if self.total == 0:
return 0
return (
self.valid
/ self.total
* 100
)
Create:
quality = DataQuality(
1000,
950
)
Now:
print(
quality.valid_percentage
)
Output:
95.0
This is a good example of a property representing a meaningful analytical metric.
Setters become even more useful when an attribute has several validation rules.
Suppose we create a student class where marks must be between 0 and 100.
class Student:
def __init__(self, marks):
self.marks = marks
@property
def marks(self):
return self._marks
@marks.setter
def marks(self, value):
if not isinstance(value, (int, float)):
raise TypeError(
"Marks must be a number"
)
if value < 0 or value > 100:
raise ValueError(
"Marks must be between 0 and 100"
)
self._marks = value
Now:
student = Student(85)
print(student.marks)
works normally.
But:
student.marks = 120
raises a validation error.
Likewise:
student.marks = "eighty"
raises a type error.
This allows the class to maintain a valid internal state.
Suppose an account contains an internal balance:
self._balance
We can expose it through a property:
@property
def balance(self):
return self._balance
Then control changes through the setter:
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError(
"Balance cannot be negative"
)
self._balance = value
The external interface remains simple:
account.balance
but the class controls the internal state.
This is an example of using properties as part of encapsulation.
Properties can also normalize input data.
For example, suppose customer names should be stored in a consistent format.
class Customer:
def __init__(self, name):
self.name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value.strip().title()
Now:
customer = Customer(
" rahul pandey "
)
Internally, the name becomes:
Rahul Pandey
The setter automatically performs the normalization.
Another common example is validating an email address.
class User:
def __init__(self, email):
self.email = email
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if "@" not in value:
raise ValueError(
"Invalid email address"
)
self._email = value.lower()
Now:
user = User(
"USER@EXAMPLE.COM"
)
The setter can normalize the value to lowercase.
The property therefore provides both validation and transformation.
Sometimes a calculated value should be readable but should not be directly assigned.
For example:
class Rectangle:
def __init__(
self,
width,
height
):
self.width = width
self.height = height
@property
def area(self):
return (
self.width
* self.height
)
Now:
rectangle = Rectangle(
10,
5
)
print(rectangle.area)
Output:
50
There is no setter for area.
Therefore, users should not directly assign:
rectangle.area = 100
The area is determined by the width and height.
This is a useful pattern for derived values.
Let’s create a more relevant Data Analytics example.
class SalesData:
def __init__(
self,
revenue,
cost
):
self.revenue = revenue
self.cost = cost
@property
def profit(self):
return (
self.revenue
- self.cost
)
@property
def profit_margin(self):
if self.revenue == 0:
return 0
return (
self.profit
/ self.revenue
* 100
)
Create:
sales = SalesData(
100000,
70000
)
Now:
print(sales.profit)
print(sales.profit_margin)
Output:
30000
30.0
The class provides analytical metrics as properties.
This makes the object intuitive to use:
sales.profit
sales.profit_margin
instead of requiring explicit method calls such as:
sales.calculate_profit()
sales.calculate_profit_margin()
For values that conceptually behave like attributes, properties can provide a cleaner interface.
We can create several analytical properties in one class.
class DataQuality:
def __init__(
self,
total,
valid,
missing
):
self.total = total
self.valid = valid
self.missing = missing
@property
def valid_percentage(self):
if self.total == 0:
return 0
return (
self.valid
/ self.total
* 100
)
@property
def missing_percentage(self):
if self.total == 0:
return 0
return (
self.missing
/ self.total
* 100
)
Now:
quality = DataQuality(
1000,
900,
100
)
We can access:
print(
quality.valid_percentage
)
print(
quality.missing_percentage
)
Output:
90.0
10.0
This demonstrates how properties can represent meaningful business or analytical metrics.
One major benefit of properties is that they provide a clean public interface.
Suppose external code uses:
employee.salary
Internally, we might initially store:
self._salary
Later, we can introduce validation:
@salary.setter
def salary(self, value):
...
The external code can continue using:
employee.salary
This means the implementation can become more sophisticated without forcing users of the class to completely change their code.
Both properties and methods can calculate values, but they communicate slightly different ideas.
| Property | Method |
|---|---|
| Usually represents a value or state | Usually represents an action or operation |
sales.profit |
sales.calculate_profit() |
| Accessed like an attribute | Called explicitly |
| Good for derived attributes | Good for operations with actions or parameters |
For example:
sales.profit
feels natural because profit represents a value.
Whereas:
sales.generate_report()
is better as a method because generating a report is an action.
Properties are useful, but they should not be used for every method.
If an operation:
a normal method may be more appropriate.
For example:
employee.generate_payslip()
is better represented as a method than:
employee.payslip
because generating a payslip is an action.
A well-designed class should provide a clear public interface.
For example:
class SalesData:
def __init__(
self,
revenue,
cost
):
self.revenue = revenue
self.cost = cost
@property
def profit(self):
return (
self.revenue
- self.cost
)
@property
def margin(self):
if self.revenue == 0:
return 0
return (
self.profit
/ self.revenue
* 100
)
The user of the class does not need to know how profit and margin are calculated.
They simply use:
sales.profit
sales.margin
The implementation remains inside the class.
This is a good example of combining encapsulation, abstraction, and properties.
In the final section, we will build a complete property-decorator project, followed by exercises, common mistakes, interview questions, and a complete revision of getters, setters, deleters, validation, and calculated properties.
In this final section, we will combine getters, setters, calculated properties, validation, and encapsulation into a practical Python OOP project. The purpose is to understand how properties can be used in a realistic application rather than simply memorizing the syntax.
We will build an Employee Analytics System that stores employee information, validates salary and performance data, calculates annual compensation and performance status, and provides controlled access to internal attributes.
Our class will contain:
We will use properties to control the salary and performance score.
class Employee:
def __init__(
self,
employee_id,
name,
salary,
performance
):
self.employee_id = employee_id
self.name = name
self.salary = salary
self.performance = performance
Notice that we use:
self.salary = salary
and:
self.performance = performance
These assignments will use the property setters once we define them.
First, create a getter:
@property
def salary(self):
return self._salary
Now create the setter:
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self._salary = float(value)
The complete salary property is:
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self._salary = float(value)
This provides two important behaviors.
When we read:
employee.salary
the getter runs.
When we write:
employee.salary = 60000
the setter runs.
The setter also ensures that salary cannot be negative.
Suppose our performance score must always be between 0 and 100.
We can enforce this rule using a property setter.
@property
def performance(self):
return self._performance
@performance.setter
def performance(self, value):
if not isinstance(value, (int, float)):
raise TypeError(
"Performance must be a number"
)
if value < 0 or value > 100:
raise ValueError(
"Performance must be between 0 and 100"
)
self._performance = value
Now invalid values cannot be stored through the public property.
Next, we can create an annual salary property.
@property
def annual_salary(self):
return self.salary * 12
Suppose the employee earns ₹50,000 per month:
employee = Employee(
"EMP101",
"Rahul",
50000,
85
)
Then:
print(employee.annual_salary)
produces:
600000.0
There is no need to store annual salary separately because it can always be calculated from monthly salary.
We can create another calculated property to classify performance.
@property
def performance_status(self):
if self.performance >= 90:
return "Excellent"
elif self.performance >= 75:
return "Good"
elif self.performance >= 50:
return "Average"
else:
return "Needs Improvement"
For an employee with a performance score of 85:
print(employee.performance_status)
the result is:
Good
This property converts a numerical value into a meaningful business category.
Now combine everything:
class Employee:
def __init__(
self,
employee_id,
name,
salary,
performance
):
self.employee_id = employee_id
self.name = name
self.salary = salary
self.performance = performance
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self._salary = float(value)
@property
def performance(self):
return self._performance
@performance.setter
def performance(self, value):
if not isinstance(value, (int, float)):
raise TypeError(
"Performance must be a number"
)
if value < 0 or value > 100:
raise ValueError(
"Performance must be between 0 and 100"
)
self._performance = value
@property
def annual_salary(self):
return self.salary * 12
@property
def performance_status(self):
if self.performance >= 90:
return "Excellent"
elif self.performance >= 75:
return "Good"
elif self.performance >= 50:
return "Average"
else:
return "Needs Improvement"
Now create an employee:
employee = Employee(
"EMP101",
"Rahul",
50000,
85
)
We can access the normal attributes:
print(employee.name)
print(employee.salary)
print(employee.performance)
We can also access calculated properties:
print(employee.annual_salary)
print(employee.performance_status)
Output:
Rahul
50000.0
85
600000.0
Good
Suppose the employee receives a salary increase.
employee.salary = 60000
Now:
print(employee.annual_salary)
automatically gives:
720000.0
We did not update annual_salary manually.
The property calculates it from the current salary.
Similarly, if the performance score changes:
employee.performance = 95
then:
print(employee.performance_status)
produces:
Excellent
This demonstrates the advantage of calculated properties.
Now test invalid salary:
employee.salary = -5000
The setter raises:
ValueError:
Salary cannot be negative
Test invalid performance:
employee.performance = 150
This also raises a validation error because performance must remain between 0 and 100.
Test invalid data type:
employee.performance = "Excellent"
The setter raises a type error because performance must be numeric.
This is a major benefit of properties: the class itself protects its internal state.
Create a Student class with:
name
marks
Create a property for marks.
The setter should:
Then create a calculated property:
grade
Use rules such as:
90-100 → A+
80-89 → A
70-79 → B
60-69 → C
50-59 → D
Below 50 → F
For example:
student = Student(
"Rahul",
87
)
print(student.marks)
print(student.grade)
The expected grade should be:
A
Create a BankAccount class with:
account_number
balance
Use a property for balance.
The setter should prevent a negative balance.
Create a calculated property:
account_status
For example:
balance >= 100000 → Premium
balance >= 50000 → Standard
balance < 50000 → Basic
Create a Product class with:
name
price
quantity
Use setters to validate:
Create a calculated property:
inventory_value
Use:
price × quantity
For example:
product = Product(
"Laptop",
50000,
10
)
print(product.inventory_value)
The result should be:
500000
Create a class called:
DataQuality
with:
total_records
valid_records
missing_records
Create properties for:
valid_percentage
missing_percentage
quality_score
For example:
total_records = 1000
valid_records = 950
missing_records = 50
The class should calculate the percentages automatically.
Mistake 1: Forgetting the underscore-backed attribute
Incorrect:
@property
def salary(self):
return self.salary
This creates recursive access because the property calls itself.
Correct:
@property
def salary(self):
return self._salary
Mistake 2: Incorrect setter name
Correct:
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
self._salary = value
The setter must be connected to the same property.
Mistake 3: Forgetting validation
If a value has business rules, those rules should be enforced consistently.
For example:
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self._salary = value
Mistake 4: Using a property for an action
A property should generally represent a value or state.
For example:
employee.annual_salary
makes sense.
But:
employee.generate_report
is less appropriate if generating the report is an action. A method such as:
employee.generate_report()
would generally be clearer.
Mistake 5: Making a property unnecessarily complicated
Properties should improve the design of a class, not make simple attributes unnecessarily difficult to understand.
1. What is the @property decorator?
@property converts a method into a property so it can be accessed using attribute syntax.
2. What is a getter?
A getter controls how a property is read.
3. What is a setter?
A setter controls how a property is assigned or modified.
4. What is a deleter?
A deleter controls what happens when a property is deleted using del.
5. Why are setters useful?
Setters allow validation, transformation, normalization, and other logic before storing a value.
6. Can a property be read-only?
Yes. A property without a setter can provide read-only behavior through the normal public interface.
7. Why do developers often use _variable with properties?
The underscore-prefixed attribute is commonly used as the internal storage attribute, while the property provides the public interface.
8. What is a calculated property?
A calculated property derives its value from other attributes instead of storing the value separately.
9. When should you use a property instead of a method?
Properties are generally appropriate when something conceptually represents a value or state. Methods are generally more appropriate for actions or operations.
10. How do properties support encapsulation?
They allow a class to control how its internal data is accessed and modified while providing a clean public interface.
| Concept | Purpose | Example |
|---|---|---|
@property |
Create getter | obj.salary |
@salary.setter |
Control assignment | obj.salary = 50000 |
@salary.deleter |
Control deletion | del obj.salary |
| Validation | Reject invalid data | Salary cannot be negative |
| Calculated property | Calculate values dynamically | obj.annual_salary |
| Read-only property | Allow controlled reading | obj.employee_id |
| Encapsulation | Control internal state | _salary |
Python property decorators provide a clean and powerful way to control access to class attributes. Instead of exposing internal data directly, properties allow developers to place logic around reading, writing, and deleting values.
The three major components are:
@property
@property_name.setter
@property_name.deleter
The getter controls reading:
employee.salary
The setter controls assignment:
employee.salary = 60000
The deleter controls deletion:
del employee.salary
Properties can also provide calculated values:
employee.annual_salary
employee.performance_status
sales.profit
sales.profit_margin
They are particularly useful for validation and encapsulation because the class can enforce rules without requiring external code to repeatedly perform the same checks.
For Data Analytics and business applications, properties can represent useful metrics such as dataset size, profit margin, data-quality percentage, inventory value, and performance scores.
The most important principle is to use properties when something conceptually behaves like an attribute but requires controlled logic behind the scenes.
By understanding properties, getters, setters, deleters, validation, calculated attributes, and encapsulation, you now have another important tool for designing robust Python OOP applications.
Next step: practice the exercises above by implementing each class yourself without looking at the solutions. This will help convert the property-decorator concepts from theory into practical Python programming skills.