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)
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)
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:
| Decorator | Role | Triggered By |
|---|---|---|
@property | Getter | obj.attr |
@attr.setter | Setter | obj.attr = value |
@attr.deleter | Deleter | del 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
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)
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}")
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}")
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)
@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}")
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.
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
- Start with plain attributes: Python's convention is to begin with public attributes. You can always add
@propertylater without breaking existing code that accesses the attribute with dot notation. - 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. - 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). - Validation runs in the setter: Because
self.attr = valuein__init__triggers the setter, validation logic protects the attribute from the moment of construction. - Read-only attributes omit the setter: A property with only a getter raises
AttributeErroron assignment, creating an immutable public interface. - Computed properties have no backing attribute: They calculate their value from other attributes on every access. Use
functools.cached_propertyif the computation is expensive and the inputs do not change. - Overriding properties in subclasses: Use
@ParentClass.attr.setteror@ParentClass.attr.getterto 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.