PYTHON / OBJECT-ORIENTED PYTHON
Properties, getters, and setters
Use @property to make attribute access run code, add validating setters and deleters, and store state in a backing attribute safely.
What you will learn
- Expose a computed value as an attribute with @property and no parentheses
- Add validation on assignment with @name.setter using the same method name
- Write to a backing attribute like _name inside the setter to avoid RecursionError
- Recognise that a property with no setter makes the attribute read-only
Understanding Properties, getters, and setters
A property is a class attribute that intercepts attribute access on instances. When you write `obj.celsius`, Python finds a `property` object on the class and calls its getter function; when you write `obj.celsius = 30`, it calls the setter. This is why Python code does not need `getName()`/`setName()` pairs up front: you start with an ordinary attribute, and if it later needs validation or computation, you replace it with a property and every existing `obj.celsius` call site keeps working unchanged.
The decorator syntax is two steps that look like one. `@property` above `def celsius` builds a property object holding only a getter and binds it to the name `celsius`. `@celsius.setter` above a second `def celsius` asks that existing property for a *copy* carrying both functions, and the copy is bound to `celsius` again. That is why both functions must have the same name: the second definition deliberately overwrites the first with the enriched property, and if you name the setter `set_celsius` you end up with two separate properties and a still-read-only `celsius`.
The mental model that keeps this straight: the property lives on the class, the actual data lives on the instance under a different name, usually `_celsius`. The setter's job is to check the incoming value and then write that private name. If a setter writes `self.celsius` instead, it calls itself forever. Because a property is a data descriptor, it also wins over the instance `__dict__`, so no instance can quietly shadow it, and `Temperature.celsius` accessed on the class gives you the property object rather than any value.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # routed through the setter below
property
def celsius(self):
return self._celsius
celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError(f"{value} is below absolute zero")
self._celsius = float(value)
property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius, t.fahrenheit)
t.celsius = 100
print(t.fahrenheit)
try:
t.celsius = -300
except ValueError as e:
print("rejected:", e)
try:
t.fahrenheit = 212
except AttributeError:
print("fahrenheit has no setter")
print(type(Temperature.celsius).__name__)A property turns attribute syntax into method calls, so `obj.x` and `obj.x = v` can validate or compute while call sites still look like plain attribute access.
Worked examples
Adding validation without changing call sites
A class that once had a plain `radius` attribute gains a validating setter, and existing code that reads `c.radius` or `c.area` is untouched.
class Circle:
def __init__(self, radius):
self.radius = radius
property
def radius(self):
return self._radius
radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(2)
print(c.area)
print(c.__dict__)
c.radius = 3
print(round(c.area, 3))Example explained
Line 1`self.radius = radius` inside `__init__` is a normal assignment, so the setter runs and bad arguments are rejected at construction time.
Line 2`c.__dict__` contains only `_radius`: the property itself is stored on the class, not on the instance.
Line 3`area` has a getter but no setter, so it is recomputed from `_radius` on every read and can never go stale.
Line 4After `c.radius = 3` the new area follows automatically because nothing cached the old result.
A property cannot be shadowed by the instance
Sneaking a value into the instance `__dict__` under the property's name has no visible effect, because a property is a data descriptor.
class Account:
def __init__(self, balance):
self._balance = balance
property
def balance(self):
print("(getter ran)")
return self._balance
a = Account(100)
a.__dict__['balance'] = 999
print(a.balance)
print(a.__dict__)Example explained
Line 1`a.__dict__['balance'] = 999` really does add a key, so the dict print shows it.
Line 2Attribute lookup checks the type for a data descriptor *before* the instance dict, so the property's getter still wins.
Line 3The `(getter ran)` line proves the function was called rather than a stored value being returned.
Line 4This precedence is what makes properties reliable invariants: no instance can bypass them by assignment.
Setter that normalises, plus a deleter
A setter can transform the incoming value instead of just checking it, and @name.deleter defines what `del obj.name` means.
class Tag:
def __init__(self, name):
self.name = name
property
def name(self):
return self._name
name.setter
def name(self, value):
self._name = value.strip().lower().replace(" ", "-")
name.deleter
def name(self):
print("clearing name")
self._name = "untagged"
t = Tag(" Python Basics ")
print(t.name)
t.name = "OOP Tricks"
print(t.name)
del t.name
print(t.name)Example explained
Line 1The setter stores a canonical form, so the getter never has to clean anything up.
Line 2Because `__init__` assigns through `self.name`, the constructor argument is normalised too.
Line 3`del t.name` calls the deleter, which here resets the backing attribute instead of removing it.
Line 4All three functions are named `name`; each decorator returns a new property carrying the previous ones.
Important notes
Callers assume attribute access is cheap, so avoid hiding a network call or heavy loop in a getter; use a normal method, or `functools.cached_property` when the result is expensive but stable.
A property only works when it is defined on the class. Assigning a property object to an instance (`obj.x = property(...)`) just stores the object and never triggers the getter.
Common mistakes
Writing `self.price = value` inside the setter instead of `self._price = value`: the setter calls itself and the program dies with RecursionError after about a thousand frames.
Naming the setter differently, e.g. `@price.setter` above `def set_price`: you create a second property called `set_price`, `price` stays read-only, and `obj.price = 5` raises AttributeError.
Calling a property like a method, `obj.price()`: the getter already returned the value, so you get `TypeError: 'int' object is not callable`.
Try it yourself
Change, predict, then run
Write a `Rectangle` class with `width` and `height` properties whose setters reject values that are not positive numbers, plus a read-only `area` property; then confirm that `Rectangle(3, 4).area` is 12, that `r.width = -1` raises ValueError, and that `r.area = 99` raises AttributeError.
Open the Python workspaceCheck your understanding
A class defines a `price` property whose setter raises ValueError for negative values, and its `__init__` body contains `self.price = price`. What happens when you call `Item(-5)`?
- ValueError is raised during construction, because the assignment in `__init__` goes through the setter like any other assignment
- The object is created with price -5, since the setter only applies to assignments made after `__init__` finishes
- The object is created but `_price` stays undefined until something assigns to `price` from outside the class
- RecursionError is raised, because assigning to `self.price` inside `__init__` re-enters the property
Show answer
`self.price = price` is ordinary attribute assignment, and Python routes every assignment to that name through the data descriptor on the class no matter where the code lives, so the validation fires during `__init__`. RecursionError is a different bug entirely: it happens only when the setter's own body assigns to `self.price` instead of `self._price`.