Home Tutorials OOP

OOP

Property Decorators in Python: @property, Getters, and Setters

Pyford Notes July 6, 2026 6 min read
Key points
  • @property lets a method be accessed like a plain attribute, with no parentheses at the call site.
  • @name.setter adds validation or side effects to attribute assignment without changing how callers use the attribute.
  • Properties let you start with plain attributes and add logic later without breaking existing code that reads or writes them.
  • A property with only a getter defined is effectively read-only — assigning to it raises AttributeError.

The problem plain attributes create

A plain instance attribute is simple and works fine until you need to add a rule about what values are acceptable. Consider a Temperature class that stores degrees Celsius as a plain attribute:

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

t = Temperature(20)
t.celsius = -500   # physically impossible, but nothing stops it

Nothing prevents an invalid value being assigned. In some languages the fix is to make the attribute private and add explicit get_celsius() and set_celsius() methods everywhere — but that changes every place in the codebase that touched t.celsius directly. Python's @property solves this without that migration cost.

A basic @property getter

Decorating a method with @property lets it be accessed without parentheses, exactly like an attribute:

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

    @property
    def diameter(self):
        return self._radius * 2

c = Circle(5)
print(c.diameter)   # 10 -- no parentheses needed

diameter is computed on every access rather than stored, so it can never drift out of sync with _radius. The leading underscore on _radius is a convention signalling "internal, but not enforced" — Python does not have true private attributes, and the underscore is a documented hint to other developers rather than a technical restriction.

Adding a setter with @x.setter

A property defined with only @property is read-only. To allow assignment, add a second method decorated with @propertyname.setter, using the same method name:

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

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

    @celsius.setter
    def celsius(self, value):
        self._celsius = value

t = Temperature(20)
t.celsius = 25   # calls the setter
print(t.celsius)  # calls the getter -> 25

To callers, t.celsius = 25 looks exactly like assigning to a plain attribute. The setter runs invisibly behind that syntax, which is the entire point — the interface does not change even though real logic now runs on every assignment.

Validation without breaking the interface

Now the setter can enforce the physical constraint that started this whole example:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius   # goes through the setter, even in __init__

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

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value

t = Temperature(20)
t.celsius = -500   # raises ValueError

Notice that __init__ assigns through self.celsius, not self._celsius directly — this routes the initial value through the same validation as every later assignment, so there is only one place the rule needs to be written.

Computed, read-only properties

Properties are equally useful for values that should never be set directly because they are derived entirely from other attributes:

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

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

r = Rectangle(4, 5)
print(r.area)   # 20
r.area = 100    # raises AttributeError -- no setter defined

Attempting to assign to area fails immediately with a clear error, which is exactly the right behaviour — there is no sensible way to "set" an area independently of width and height, so silently accepting the assignment would only hide a bug.

When a plain attribute is still the right call

Not every attribute needs a property. If there is no validation to enforce and nothing being computed, a plain attribute is simpler and just as correct — adding @property without a reason is extra code for no benefit. The practical pattern is to start every attribute as a plain one, and convert it to a property only at the point you actually need validation, computation, or a side effect on access. Because the calling syntax is identical either way, that conversion never requires touching the code that uses the attribute.

This is one of the more concrete benefits of properties over Java-style getters and setters written from the start: nothing about a Python class's public interface has to anticipate future validation needs. A class can ship with plain attributes, gain a rule six months later, and every existing caller keeps working unchanged, because obj.attr and obj.attr = value look identical whether they hit a plain attribute or a property underneath.

One caveat worth knowing: a property defined on a class applies to every instance of that class, not to one object individually. If you need per-instance control over which attributes are validated, that decision has to be expressed some other way, typically by configuring the class itself rather than by trying to attach a one-off property to a single object.