Properties: Getters and Setters Done the Python Way
@property turns a method into an attribute. It lets you start with a plain attribute and add validation later without changing a single line of calling code.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Start with a plain attribute
class Circle:
def __init__(self, radius):
self.radius = radius
c = Circle(5)
print(c.radius)
c.radius = 10In many languages this is considered unsafe, so every field gets a getter and a setter from the start. Python does not need that, because an attribute can be converted into a property later and every caller keeps working unchanged.
Adding validation without breaking callers
class Circle:
def __init__(self, radius):
self.radius = radius # goes through the setter below
@property
def radius(self):
"""The radius, which must be positive."""
return self._radius
@radius.setter
def radius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("radius must be a number")
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
c = Circle(5)
print(c.radius) # 5 - identical syntax to before
c.radius = 10 # validated
try:
c.radius = -3
except ValueError as error:
print(error)Nothing at the call site changed. That is the entire argument for not writing get_radius() and set_radius() in Python.
Note that__init__assignsself.radius, notself._radius. This deliberately routes construction through the setter, so an object can never be created in an invalid state.
Computed properties
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(4, 5)
print(r.area, r.perimeter, r.is_square) # 20 18 False
# r.area = 100 # AttributeError: property has no setterA read only property is the right shape for anything derived from other attributes. It cannot go stale, because it is recomputed on every access, and it cannot be set to a value that contradicts the rest of the object.
The three parts
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
"""Temperature in degrees Celsius."""
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("resetting to zero")
self._celsius = 0
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9 # reuse the validation
t = Temperature(25)
print(t.celsius, t.fahrenheit) # 25 77.0
t.fahrenheit = 212
print(t.celsius) # 100.0 - kept consistent automatically
del t.celsius
print(t.celsius) # 0Two views of one underlying value. There is no way for the Celsius and Fahrenheit readings to disagree, because only one of them is stored.
Lazy and cached properties
class Report:
def __init__(self, rows):
self.rows = rows
self._summary = None
@property
def summary(self):
if self._summary is None: # compute once, on first access
print("computing...")
self._summary = {
"count": len(self.rows),
"total": sum(self.rows),
"mean": sum(self.rows) / len(self.rows),
}
return self._summary
r = Report([10, 20, 30])
print(r.summary) # computing... then the dictionary
print(r.summary) # no recomputationfrom functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def summary(self):
print("computing...")
return {"count": len(self.rows), "total": sum(self.rows)}
r = Report([10, 20, 30])
print(r.summary)
print(r.summary) # cached
del r.summary # clear the cache if the data changed
print(r.summary)cached_property stores the result in the instance dictionary, so later accesses skip the method entirely. Use it only when the underlying data does not change, or clear it when it does.
Properties in inheritance
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
@property
def annual(self):
return self._salary * 12
class Manager(Employee):
BONUS = 100_000
@property
def annual(self):
return super().annual + self.BONUS # extend the parent property
m = Manager(90_000)
print(f"{m.annual:,}") # 1,180,000A worked example
class Product:
"""A product whose price and discount stay consistent with each other."""
def __init__(self, name, price, discount_percent=0):
self.name = name
self.price = price
self.discount_percent = discount_percent
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)):
raise TypeError("price must be a number")
if value < 0:
raise ValueError("price cannot be negative")
self._price = round(float(value), 2)
@property
def discount_percent(self):
return self._discount_percent
@discount_percent.setter
def discount_percent(self, value):
if not 0 <= value <= 90:
raise ValueError("discount must be between 0 and 90")
self._discount_percent = value
@property
def discount_amount(self):
return round(self._price * self._discount_percent / 100, 2)
@property
def final_price(self):
return round(self._price - self.discount_amount, 2)
@property
def is_on_sale(self):
return self._discount_percent > 0
def __repr__(self):
return f"Product({self.name!r}, {self._price}, {self._discount_percent}%)"
p = Product("Notebook", 250, 20)
print(p)
print(p.discount_amount, p.final_price, p.is_on_sale)
p.price = 300
print(p.final_price) # recalculated automatically
for bad in [-10, "free"]:
try:
p.price = bad
except (ValueError, TypeError) as error:
print(f"{bad!r}: {error}")When not to use a property
# Wrong: a property that is slow or has side effects
class Account:
@property
def balance(self):
return fetch_from_database(self.id) # a network call behind a dot
# Right: make the cost visible
class Account:
def fetch_balance(self):
return fetch_from_database(self.id)A property should look and feel like an attribute: fast, side effect free, and safe to read twice. Anything that hits a database, a file or a network deserves to be a method, so the reader can see it costs something.
Common mistakes
- Naming the property and the stored attribute the same, causing infinite recursion:
class Broken:
@property
def value(self):
return self.value # RecursionError - calls itself forever- Forgetting the setter, then being surprised that assignment raises
AttributeError. - Using
@propertyfor something expensive. - Writing
get_xandset_xmethods out of habit from another language. - Caching with
cached_propertyand never invalidating it when the data changes. - Putting validation only in
__init__, so later assignment bypasses it.
Best practices
- Start with a plain public attribute; add a property only when it earns its place.
- Store the value under a single underscore name and expose it without.
- Assign through the property in
__init__so construction is validated too. - Use read only properties for derived values.
- Keep properties cheap, and use a method when the work is real.
Practice
- Convert a class using
get_nameandset_nameinto one using a property, leaving callers untouched. - Write a
Personclass with adate_of_birthattribute and a read onlyageproperty. - Create a class with two units of the same measurement, kept consistent through properties.
- Demonstrate the recursion error caused by naming a property after its own attribute, then fix it.
- Add
cached_propertyto an expensive calculation and show how to invalidate it.
Conclusion
@property is why Python does not need getters and setters. Expose attributes directly, and when a rule or a computed value becomes necessary, convert it to a property - the interface stays exactly the same.