A @property turns a method into something you read like an attribute, and pairing it with a getter and a setter is what gives you validation for free. You call obj.balance instead of obj.get_balance(), but behind that syntax sits a full method that Python runs on every access — which is exactly what makes properties the right place for validation and computed values.
The important thing to understand is that a property is not a special attribute. It is a descriptor: a class attribute that intercepts access to an instance attribute of the same name. That is why you can add validation to an assignment that already exists in code that reads the attribute as if nothing changed.
How do you make a read-only attribute?
Define a method, decorate it with @property, and return the value. The method becomes the getter, and the name it is defined under is what you use at the call site — without parentheses.
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def area(self):
return self._width * self._height
r = Rectangle(3, 4)
print("area:", r.area)
try:
r.area = 99
except AttributeError as exc:
print("AttributeError:", exc)
Attempting to assign to it raises AttributeError, because there is no setter. That is a genuine guarantee, not a convention: the name simply cannot be rebound from outside the class.
Note the naming convention used throughout this page. The property is named area and the underlying storage is _area, with a single leading underscore. The underscore signals "private by convention" — Python will not stop you from touching it, but it tells other developers that the attribute is an implementation detail.
How do you add a setter that validates?
Define a second method with the same name, decorate it with @name.setter, and have it do the work. Python routes assignments to that method instead of storing the value directly.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise TypeError(f"age must be an int, got {type(value).__name__}")
if not 0 <= value <= 150:
raise ValueError(f"age {value} is out of range")
self._age = value
p = Person("Asha", 30)
print(p.name, p.age)
p.age = 31
print("updated:", p.age)
try:
p.age = 200
except ValueError as exc:
print("ValueError:", exc)
The decorator takes its name from the function, so the method must be called exactly age to attach to the age property. Getter and setter always come as a pair — you cannot have a setter without a getter, since the getter is what defines the property in the first place.
Two details worth copying. The setter should validate before it stores, and it should raise a meaningful exception type: ValueError for a value that is the wrong shape or range, TypeError for a value of the wrong type. And the setter should store through the backing attribute self._age, not through self.age — assigning to self.age inside the setter would call the setter again and recurse forever.
When should a property compute a value?
When the value is derived from other state and should not be stored at all. A full name, a price with tax applied, a formatted timestamp — these should be computed on demand rather than kept in sync by hand.
class User:
def __init__(self, first, last):
self.first = first
self.last = last
@property
def full_name(self):
return f"{self.first} {self.last}"
@property
def initials(self):
return (self.first[0] + self.last[0]).upper()
u = User("Grace", "Hopper")
print("full name:", u.full_name)
print("initials :", u.initials)
Computing on demand has a cost, and for a hot loop it shows: the property body runs on every read. For something like full_name that is nothing. For something expensive, cache the result — either in a private attribute you refresh yourself, or with functools.cached_property, which computes once and stores the result in the instance dictionary.
What if I already shipped a plain attribute?
This is the pattern that makes properties worth learning early. You have a class with self.price = price in __init__, and callers read obj.price everywhere. Now you need validation. You can add a property without touching a single caller.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def show_price(product):
return product.price
legacy = Product("keyboard", 79.5)
print("the old class still works:", show_price(legacy))
class Product:
def __init__(self, name, price):
self.name = name
self._price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"price must be a number, got {type(value).__name__}")
if value < 0:
raise ValueError("price cannot be negative")
self._price = value
upgraded = Product("keyboard", 79.5)
print("the new class serves the same call:", show_price(upgraded))
upgraded.price = 60.0
print("now it validates:", upgraded.price)
try:
upgraded.price = -5
except ValueError as exc:
print("ValueError:", exc)
Look at the show_price function: it is written once and works with both classes. Nothing in the calling code changed, yet the second class now rejects bad prices. That is the whole point of the property — the public interface stays identical while the behaviour underneath becomes stricter.
The migration path is always the same. Add a property with a getter that returns the private attribute, add a setter that validates and then writes to the private attribute, and rename the old assignment in __init__ to use the private name. Every existing caller keeps working, and new behaviour is enforced from that point on.
Is there a deleter, and what are the common patterns?
There is. @name.deleter defines what happens on del obj.name. It is rarely needed, but it completes the pattern and it costs one decorator.
class Temp:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
@celsius.deleter
def celsius(self):
print("clearing the reading")
del self._celsius
t = Temp(21.5)
print(t.celsius)
t.celsius = 25.0
print(t.celsius)
del t.celsius
print("the private attribute is gone:", not hasattr(t, "_celsius"))
Two patterns recur. The guarded attribute keeps a private backing field and exposes a validated property — that is what the earlier examples show. The read-only computed value exposes a derived result and offers no setter at all. A third pattern, the settable computed value, stores nothing and simply recomputes the backing state from what you assign, which works when the value has a single canonical form.
One thing to avoid: using a property to hide a method that happens to be called once. Properties signal "this is a value", and callers will read it that way. If it performs work and takes arguments, make it a method.
| You want | Write |
|---|---|
| a value the caller reads | @property |
| a value the caller also assigns | @property plus @name.setter |
| a value nobody may change | @property with no setter |
| an expensive value used many times | @functools.cached_property |
| an action with arguments | a plain method |
Patterns to reuse
@propertyturns a method into an attribute-style read.- A getter with no setter is genuinely read-only; assignment raises
AttributeError. - Add a setter with
@name.setter, validate there, and store to the private attribute. - Use properties for validation and computed values, not as a wrapper around ordinary work.
- Adding a property later is a safe, backwards-compatible way to introduce validation.
Try it yourself: retrofit validation onto an existing class
Start with a plain Product class that has name, price and stock attributes set directly in __init__. Now add a property for price that rejects negative values with ValueError and non-numeric types with TypeError. Add a read-only property in_stock that returns a boolean.
Verify that existing code reading product.price still works with no changes, and that product.price = -5 now raises. Then try to assign to product.in_stock and confirm it fails. As a final step, add a discount property that accepts a percentage between 0 and 100 and exposes a computed discounted_price, checking that setting a discount of 150 raises the error you wrote.