Python - Python Property Decorators and Managed Attributes

Introduction

In Python, a class normally allows its attributes to be accessed and modified directly. For example, if a class has an attribute called age, we can write person.age = 25 to assign a value and print(person.age) to retrieve it. Although this approach is simple, there are situations where we need more control over how an attribute is read, modified, or deleted.

Python provides the @property decorator for this purpose. A property allows a method to be accessed like a normal attribute. This makes it possible to add validation, calculations, formatting, or other logic while keeping a simple and readable syntax for users of the class.

Property decorators are especially useful when implementing managed attributes, where the class controls how its internal data is accessed and changed.

What Is a Property?

A property is a special kind of attribute that is controlled by methods. Instead of directly exposing an internal variable, a class can provide a property through which the variable is accessed.

Consider a simple class:

class Student:
    def __init__(self, name):
        self.name = name

student = Student("Rahul")
print(student.name)

Here, name is a normal instance attribute. It can be accessed and changed directly.

student.name = "Anil"

There is no validation or additional logic involved.

With a property, we can control what happens when the attribute is accessed.

class Student:
    def __init__(self, name):
        self._name = name

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

Now the name() method behaves like an attribute:

student = Student("Rahul")
print(student.name)

The important point is that we do not write:

student.name()

Instead, we use:

student.name

The @property decorator converts the method into a property.

Why Use the @property Decorator?

Properties are useful when an attribute requires additional control.

For example, suppose a student's age should never be negative. With a normal attribute, Python allows this:

student.age = -10

A property can prevent invalid values.

class Student:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

Now:

student = Student(20)

print(student.age)

student.age = 25
print(student.age)

The setter validates the value before storing it.

If we attempt:

student.age = -5

Python raises a ValueError.

This makes properties useful for maintaining valid object states.

Getter in Python Properties

The method decorated with @property is commonly called the getter.

Example:

class Employee:
    def __init__(self, salary):
        self._salary = salary

    @property
    def salary(self):
        return self._salary

The getter is executed whenever we access:

employee.salary

It internally executes:

return self._salary

The underscore in _salary is a naming convention indicating that the attribute is intended for internal use.

The property provides a controlled public interface:

employee.salary

while the actual stored value is:

employee._salary

Setter in Python Properties

A setter defines what should happen when a property is assigned a new value.

The @property_name.setter decorator is used to create a setter.

class Employee:
    def __init__(self, salary):
        self._salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self._salary = value

Now:

employee = Employee(50000)

employee.salary = 60000
print(employee.salary)

When this statement executes:

employee.salary = 60000

Python calls the setter automatically.

The setter checks the value and then stores it in _salary.

Getter and Setter Together

A property can have both a getter and a setter.

class Product:
    def __init__(self, price):
        self._price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("Price must be greater than zero")
        self._price = value

Using the class:

product = Product(100)

print(product.price)

product.price = 150

print(product.price)

The getter retrieves the value, while the setter controls modifications.

This provides a clean interface:

product.price
product.price = 150

Instead of exposing internal implementation details.

Property Validation

One of the most common applications of properties is validation.

Consider a bank account:

class BankAccount:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

The class ensures that the balance cannot become negative through the property.

account = BankAccount(5000)

account.balance = 6000
print(account.balance)

An invalid assignment is rejected:

account.balance = -1000

This type of validation is valuable in applications where data must satisfy specific rules.

Read-Only Properties

A property does not necessarily need a setter.

For example:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius * self.radius

The area property can be read:

circle = Circle(5)

print(circle.area)

But there is no setter for area.

Therefore, users should not assign a new value to it:

circle.area = 100

A read-only property is useful when a value should be calculated from other attributes rather than independently stored.

Calculated Properties

Properties can also represent calculated values.

For example:

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    @property
    def area(self):
        return self.length * self.width

    @property
    def perimeter(self):
        return 2 * (self.length + self.width)

Now:

rectangle = Rectangle(10, 5)

print(rectangle.area)
print(rectangle.perimeter)

There is no need to store area or perimeter separately. They are calculated whenever they are requested.

This reduces unnecessary duplicated data.

Property Deleter

Python also allows a property to define what happens when an attribute is deleted.

The @property_name.deleter decorator is used for this purpose.

class User:
    def __init__(self, name):
        self._name = name

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

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

    @name.deleter
    def name(self):
        print("Deleting name")
        del self._name

Now:

user = User("Rahul")

print(user.name)

del user.name

When del user.name is executed, the property deleter is called.

Complete Property Example

The following example demonstrates getter, setter, and deleter together:

class Student:
    def __init__(self, name, marks):
        self._name = name
        self._marks = marks

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

    @name.setter
    def name(self, value):
        if not value:
            raise ValueError("Name cannot be empty")
        self._name = value

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if value < 0 or value > 100:
            raise ValueError("Marks must be between 0 and 100")
        self._marks = value

Using the class:

student = Student("Rahul", 85)

print(student.name)
print(student.marks)

student.name = "Anil"
student.marks = 90

print(student.name)
print(student.marks)

Here, the properties ensure that invalid names and marks are not accepted.

Difference Between a Normal Attribute and a Managed Attribute

A normal attribute is directly accessed and modified:

class Student:
    def __init__(self, marks):
        self.marks = marks

There is no validation:

student.marks = -50

A managed attribute uses a property:

class Student:
    def __init__(self, marks):
        self._marks = marks

    @property
    def marks(self):
        return self._marks

    @marks.setter
    def marks(self, value):
        if value < 0:
            raise ValueError("Marks cannot be negative")
        self._marks = value

Now the class has control over how marks is changed.

This is the main idea behind managed attributes.

Why Use an Underscore?

You will frequently see code such as:

self._name

and:

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

The underscore distinguishes the internal storage variable from the public property.

The property is:

name

The internal attribute is:

_name

This avoids a naming conflict.

If we attempted:

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

the property would call itself repeatedly and eventually cause a recursion error.

Using _name avoids this problem.

Advantages of Property Decorators

Properties provide several important benefits.

Data Validation

They allow a class to reject invalid values before storing them.

Encapsulation

They hide implementation details and provide controlled access to internal data.

Read-Only Data

A property without a setter can prevent users from directly changing a calculated or protected value.

Computed Values

Properties can calculate values dynamically instead of storing duplicate information.

Cleaner Syntax

A method can be accessed using attribute syntax:

object.value

rather than:

object.get_value()

Easier Code Evolution

A class can initially expose a normal-looking attribute and later introduce validation or calculation through a property without changing how calling code accesses it.

Property Decorator vs Traditional Getter and Setter Methods

Before properties, developers commonly used methods such as:

class Student:
    def get_marks(self):
        return self._marks

    def set_marks(self, value):
        self._marks = value

The caller would use:

student.get_marks()
student.set_marks(90)

With properties, the interface becomes:

student.marks
student.marks = 90

The property approach is often more natural because the operation looks like normal attribute access while still allowing the class to execute additional logic.

Important Points to Remember

The @property decorator creates a getter for an attribute.

The @property_name.setter decorator creates a setter.

The @property_name.deleter decorator creates a deleter.

A property without a setter can effectively be used as a read-only attribute.

Properties are commonly used for validation, encapsulation, calculated values, and controlled access to internal attributes.

The internal variable commonly uses a leading underscore, such as _age, _salary, or _name.

Properties allow developers to maintain a simple public interface while keeping control over the internal implementation of a class.

Conclusion

Python property decorators provide a powerful way to create managed attributes. Instead of allowing unrestricted access to an object's internal data, properties let a class decide how values are retrieved, modified, calculated, or deleted.

The key components are the getter using @property, the setter using @attribute.setter, and the optional deleter using @attribute.deleter. Together, they make classes more reliable, maintainable, and easier to use while supporting important object-oriented programming principles such as encapsulation and controlled data access.