How to Use @property Decorator for Getters and Setters

In languages like Java and C#, getter and setter methods are everywhere. Python takes a different approach. You start with plain public attributes, and if you later need validation, computation, or logging when an attribute is accessed or modified, you upgrade to @property without changing a single line of calling code. This article covers the complete progression from bare attributes through explicit getter/setter methods to the @property decorator, with runnable code at every step.

The Problem @property Solves

Consider a class that starts with a simple public attribute:

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


u = User("Kandi", 30)
print(u.age)
u.age = -5       # no validation, accepts nonsense
print(u.age)
Output 30 -5

There is no validation. Anyone can assign a negative age. The traditional fix in other languages is to hide the attribute behind explicit getter and setter methods:

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

    def get_age(self):
        return self._age

    def set_age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError("Age must be a non-negative integer")
        self._age = value

This works, but it breaks every line of code that was using u.age directly. Every caller now has to switch to u.get_age() and u.set_age(value). Python's @property solves this by letting you add the same validation logic while keeping the original u.age attribute-access syntax intact:

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age      # triggers the setter

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

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError("Age must be a non-negative integer")
        self._age = value


u = User("Kandi", 30)
print(u.age)              # calls the getter

u.age = 35                # calls the setter
print(u.age)

try:
    u.age = -5            # setter raises ValueError
except ValueError as e:
    print(e)
Output 30 35 Age must be a non-negative integer

The calling code still uses u.age as if it were a plain attribute. The @property decorator intercepts the access and assignment behind the scenes.

Anatomy of a Property: Getter, Setter, Deleter

A property can have up to three components. Each is a method with the same name, distinguished by its decorator:

DecoratorRoleTriggered By
@propertyGetterobj.attr
@attr.setterSetterobj.attr = value
@attr.deleterDeleterdel obj.attr

The getter decorated with @property must be defined first. The setter and deleter decorators are derived from the getter's name. All three methods must share the same name:

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

    @property
    def price(self):
        """The retail price in dollars."""
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = round(value, 2)

    @price.deleter
    def price(self):
        print(f"Clearing price for {self._name}")
        del self._price


item = Product("Widget", 19.999)
print(item.price)

del item.price
Output 20.0 Clearing price for Widget
Note

The docstring goes on the getter method. Python propagates it to the property object, so help(Product.price) displays it correctly.

Validation Inside the Setter

The setter is where validation logic belongs. Because self.price = value in __init__ triggers the setter, validation runs even during object construction:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"Expected number, got {type(value).__name__}")
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible")
        self._celsius = float(value)

    @property
    def fahrenheit(self):
        return (self._celsius * 9 / 5) + 32


t = Temperature(100)
print(f"{t.celsius}C = {t.fahrenheit}F")

t.celsius = 0
print(f"{t.celsius}C = {t.fahrenheit}F")

try:
    Temperature(-300)
except ValueError as e:
    print(e)
Output 100.0C = 212.0F 0.0C = 32.0F Temperature below absolute zero is not possible

The Temperature(-300) call fails inside __init__ because self.celsius = celsius routes through the setter, which rejects the value before _celsius is ever stored.

Read-Only Properties

If you define a getter without a setter, the attribute becomes read-only. Any assignment raises AttributeError:

class Point:
    def __init__(self, x, y):
        self._x = x
        self._y = y

    @property
    def x(self):
        return self._x

    @property
    def y(self):
        return self._y

    def __repr__(self):
        return f"Point({self._x}, {self._y})"


p = Point(3, 7)
print(p.x, p.y)

try:
    p.x = 10
except AttributeError as e:
    print(f"Blocked: {e}")
Output 3 7 Blocked: property 'x' of 'Point' object has no setter

This pattern is useful for immutable value objects where coordinates, IDs, or timestamps should never change after initialization.

Computed Properties

Properties do not need a backing _attribute at all. A computed property calculates its value from other attributes on each access:

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)

    @property
    def is_square(self):
        return self.width == self.height


r = Rectangle(5, 10)
print(f"Area: {r.area}")
print(f"Perimeter: {r.perimeter}")
print(f"Square: {r.is_square}")

r.width = 10
print(f"Area after resize: {r.area}")
print(f"Square now: {r.is_square}")
Output Area: 50 Perimeter: 30 Square: False Area after resize: 100 Square now: True
Pro Tip

Computed properties recalculate on every access. If the computation is expensive, consider caching the result. Python 3.8+ provides functools.cached_property for exactly this case — it computes the value once and stores it as an instance attribute.

The Deleter Method

The deleter is less common but useful when removing an attribute should trigger cleanup, logging, or resetting to a default state:

class Config:
    def __init__(self, theme="dark"):
        self._theme = theme

    @property
    def theme(self):
        return self._theme

    @theme.setter
    def theme(self, value):
        allowed = ("dark", "light", "system")
        if value not in allowed:
            raise ValueError(f"Theme must be one of {allowed}")
        self._theme = value

    @theme.deleter
    def theme(self):
        print("Resetting theme to default")
        self._theme = "dark"


cfg = Config("light")
print(cfg.theme)

del cfg.theme
print(cfg.theme)
Output light Resetting theme to default dark

@property vs. property() Built-in

The @property decorator is syntactic sugar for the property() built-in function. These two class definitions are equivalent:

# Using the property() function directly
class Circle:
    def __init__(self, radius):
        self._radius = radius

    def _get_radius(self):
        return self._radius

    def _set_radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    radius = property(_get_radius, _set_radius, doc="The circle radius.")
# Using the @property decorator (preferred)
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        """The circle radius."""
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

The decorator form is preferred because it keeps the getter, setter, and deleter grouped by name rather than spread across separate methods with different names. It is also the style recommended by PEP 8.

Properties and Inheritance

Properties work with inheritance, but overriding them requires care. If a subclass needs to modify only the setter, it must redefine the entire property because the setter is part of the property object, not an independent method:

class Account:
    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


class PremiumAccount(Account):
    @Account.balance.setter
    def balance(self, value):
        if value < -1000:
            raise ValueError("Overdraft limit is -1000")
        self._balance = value


standard = Account(500)
premium = PremiumAccount(500)

premium.balance = -500     # allowed for premium
print(f"Premium: {premium.balance}")

try:
    standard.balance = -500  # blocked for standard
except ValueError as e:
    print(f"Standard: {e}")
Output Premium: -500 Standard: Balance cannot be negative

The @Account.balance.setter syntax creates a new property that inherits the getter from Account but replaces the setter with the one defined in PremiumAccount.

Common Mistakes

Naming the Private Attribute the Same as the Property

# WRONG: causes infinite recursion
class Broken:
    def __init__(self, value):
        self.value = value

    @property
    def value(self):
        return self.value    # calls itself endlessly

    @value.setter
    def value(self, v):
        self.value = v       # calls itself endlessly

The getter returns self.value, which triggers the getter again, creating infinite recursion. The backing attribute must have a different name — by convention, a leading underscore: self._value.

Defining the Setter Before the Getter

The @property decorator must come first because it creates the property object. The setter decorator (@name.setter) attaches to an existing property. If the property does not exist yet, Python raises a NameError.

Using @property on Methods That Need Arguments

A property getter receives only self. It cannot accept additional arguments. If you need parameters, use a regular method instead of a property.

When Not to Use @property

If your getter and setter do nothing beyond reading and writing the attribute, skip @property entirely and use a plain public attribute. Properties add complexity. Use them only when access or assignment needs validation, computation, logging, or other side effects.

Key Takeaways

  1. Start with plain attributes: Python's convention is to begin with public attributes. You can always add @property later without breaking existing code that accesses the attribute with dot notation.
  2. The getter must come first: Decorate the first method with @property. The setter and deleter decorators (@name.setter, @name.deleter) attach to the property object that the getter creates.
  3. All three methods share the same name: The getter, setter, and deleter are all named after the property. The backing attribute uses a different name, typically with a leading underscore (_name).
  4. Validation runs in the setter: Because self.attr = value in __init__ triggers the setter, validation logic protects the attribute from the moment of construction.
  5. Read-only attributes omit the setter: A property with only a getter raises AttributeError on assignment, creating an immutable public interface.
  6. Computed properties have no backing attribute: They calculate their value from other attributes on every access. Use functools.cached_property if the computation is expensive and the inputs do not change.
  7. Overriding properties in subclasses: Use @ParentClass.attr.setter or @ParentClass.attr.getter to selectively override one component while inheriting the others.

The @property decorator is the standard Python mechanism for turning methods into managed attributes. It keeps your class interface clean, your validation centralized, and your calling code unchanged.

Frequently Asked Questions

What does the @property decorator do in Python?

The @property decorator turns a method into a managed attribute. When you access the attribute, Python calls the getter method behind the scenes. Combined with @name.setter and @name.deleter, it lets you add validation, computation, or side effects to attribute access and assignment while keeping clean dot-notation syntax.

How do I create a setter with @property?

First define the getter method decorated with @property. Then define a second method with the same name, decorated with @name.setter, where name matches the getter. The setter method receives self and the new value as parameters. Python calls this method automatically when you assign to the attribute.

Can I make a read-only attribute with @property?

Yes. Define only the getter method with @property and omit the setter. Any attempt to assign a value to the attribute will raise an AttributeError, making the attribute effectively immutable from outside the class.

What is the difference between property() and @property?

They are functionally identical. The property() built-in function takes getter, setter, and deleter functions as arguments and returns a property object. The @property decorator is syntactic sugar that achieves the same result with cleaner syntax by decorating methods directly in the class body.