The Story That Explains Properties
Door 1 — walk into the vault yourself. You could grab any file, change any number. That's a public attribute —
acc.balance = -999999 would work, no
questions asked.Door 2 — fill out request forms. Form A for "get balance," Form B for "deposit," Form C for "close account." Every read and write means calling
acc.get_balance() or acc.set_balance(500). Safe, but ugly and
verbose.Door 3 — the friendly teller. You just say "what's my balance?" (
acc.balance). Behind the counter, the teller checks records, applies rules,
logs the transaction, and returns the answer. You didn't fill out a form — but validation
still happened. Change your balance? Say acc.balance = 5000, and the teller
validates before writing.The teller is Python's @property. From outside, it looks and behaves like a plain attribute. Inside, it runs a method — with getter for reads, setter for writes, and deleter for cleanup.
A property is a special attribute whose read, write, and delete operations
are secretly method calls. Callers write obj.balance — but under the hood,
Python fires your @property for reads, your @balance.setter for
writes, and your @balance.deleter for del obj.balance. Clean
syntax on the outside, full control on the inside.
The Problem Properties Solve
There are three ways to expose data from a class. Two of them have serious downsides. Properties are the third — and the reason they exist.
# ── Option 1: Public attribute — unsafe ────────────────────
class BankAccount:
def __init__(self, balance):
self.balance = balance
acc = BankAccount(1000)
acc.balance = -999999 # nothing stops this — the class contract is gone
# ── Option 2: Java-style getter/setter methods — ugly ──────
class BankAccount:
def __init__(self, balance):
self._balance = balance
def get_balance(self):
return self._balance
def set_balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
acc = BankAccount(1000)
print(acc.get_balance()) # 1000 ← works, but every read is a function call in the code
acc.set_balance(1500) # works — but the class API is verbose
# ── Option 3: @property — safe AND clean ──────────────────
class BankAccount:
def __init__(self, balance):
self.balance = balance # goes through the setter — automatic validation
@property
def balance(self): # getter
return self._balance
@balance.setter
def balance(self, value): # setter
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
acc = BankAccount(1000)
print(acc.balance) # 1000 ← looks like a plain attribute...
acc.balance = 1500 # ...but the setter enforces rules under the hood
With properties, you can start with a plain public attribute
(self.balance = ...) and later upgrade it to a validated
property — without breaking any code that already uses acc.balance. That's the
quiet superpower: your class's external API stays the same while the internals gain rules.
@property — The Getter
The @property decorator turns a method into a read-only attribute.
Callers write obj.balance, but Python secretly calls the method. Nothing is stored
as an attribute; the value comes from whatever the method returns.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@property
def area(self):
# Computed on the fly — no stored attribute
return 3.14159 * self._radius ** 2
@property
def circumference(self):
return 2 * 3.14159 * self._radius
c = Circle(5)
print(c.radius) # 5 ← looks like an attribute
print(c.area) # 78.53975 ← same syntax, but computed
print(c.circumference) # 31.4159
# Trying to write to a property that has no setter
try:
c.area = 100
except AttributeError as e:
print("Blocked:", e) # property 'area' of 'Circle' object has no setter
Animated Diagram — A Property Looks Like an Attribute, Behaves Like a Method
Every c.area lookup silently runs the getter and returns whatever it computes — no stored attribute, no ceremony.
@x.setter — Validated Writes
A property with only @property is read-only. To allow writes, add
a matching @name.setter method. This is where validation lives — the setter
intercepts every assignment and can reject bad values before they touch the object.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age # goes THROUGH the setter
@property
def age(self): # GETTER
return self._age
@age.setter
def age(self, value): # SETTER — same method name!
if not isinstance(value, int):
raise TypeError("age must be an int")
if value < 0 or value > 150:
raise ValueError("age must be 0..150")
self._age = value
alice = Person("Alice", 30)
print(alice.age) # 30 ← getter runs
alice.age = 31 # setter runs, passes validation
print(alice.age) # 31
# ── Bad values are rejected AT THE SETTER, before touching the object ──
try:
alice.age = -5
except ValueError as e:
print("Rejected:", e) # age must be 0..150
try:
alice.age = "forty"
except TypeError as e:
print("Rejected:", e) # age must be an int
Animated Diagram — Setter Validation Flow
Watch two write attempts. alice.age = 31 passes validation and is stored.
alice.age = -5 is caught by the setter and rejected — the underlying
_age is never touched.
The setter is a filter. Good values pass through to storage. Bad values raise an exception and the object stays untouched.
Never store the value in the property's own name (self.age inside the setter).
That would recurse forever — self.age = value would call the setter again.
The universal convention: property named x stores under
_x. That's why the setter writes to self._age, not
self.age.
@x.deleter — Custom Cleanup on del obj.x
The third and least-used piece of the property trio. A deleter runs when a
caller writes del obj.x. Use it when deleting the property should do more than just
remove the attribute — resetting to a default, closing a connection, logging.
class Session:
def __init__(self, token):
self._token = token
@property
def token(self):
return self._token
@token.setter
def token(self, value):
if not isinstance(value, str) or len(value) < 8:
raise ValueError("token must be a string of length ≥ 8")
self._token = value
@token.deleter
def token(self):
# Custom cleanup — logout, revoke, wipe
print(f"Logging out session for token '{self._token[:4]}...'")
self._token = None
s = Session("abc-secret-token")
print(s.token) # abc-secret-token
del s.token # deleter runs → "Logging out session for token 'abc-...'"
print(s.token) # None
Animated Diagram — The Full Property Lifecycle
One property, three operations, three separate methods behind the scenes.
Read (x = obj.balance) → getter fires. Write
(obj.balance = 100) → setter fires. Delete
(del obj.balance) → deleter fires.
One property name, three decorated methods. Python routes each access — read, write, delete — to the correct one automatically.
Special Cases — Read-Only and Computed Properties
Not every property needs all three of getter, setter, and deleter. Two very common patterns:
@property. Any attempt to write raises
AttributeError: property has no setter. Perfect for IDs, hashes, or values
derived from other state.
fahrenheit from celsius, full_name from
first + last, age from a birth date.
fahrenheit = 100 → underlying
_celsius updates automatically.
class Person:
def __init__(self, first, last, birth_year):
self.first = first
self.last = last
self._birth_year = birth_year
# ── COMPUTED read-only property ──
@property
def full_name(self):
return f"{self.first} {self.last}"
# ── READ-ONLY property (no setter) ──
@property
def birth_year(self):
return self._birth_year
# ── COMPUTED FROM current year — no storage ──
@property
def age(self, _year=2026):
return _year - self._birth_year
alice = Person("Alice", "Roy", 1995)
print(alice.full_name) # Alice Roy (computed)
print(alice.age) # 31 (computed)
print(alice.birth_year) # 1995
# Change one component → dependents update automatically
alice.first = "Alicia"
print(alice.full_name) # Alicia Roy (recomputed on the fly)
# Read-only — trying to write fails
try:
alice.birth_year = 1990
except AttributeError as e:
print("Blocked:", e)
Practical Example 1 — Temperature With Two-Way Conversion
A classic use of properties: one stored value (celsius), multiple views of it (fahrenheit, kelvin). Set fahrenheit — the underlying celsius updates. Read celsius — you get the stored value directly. Every write is validated against absolute zero.
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius # goes through the setter — validated at construction
# ══ CELSIUS: stored, with validation ═══════════════════════
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError(f"{value}°C is below absolute zero")
self._celsius = value
@celsius.deleter
def celsius(self):
print("Resetting temperature to 0°C")
self._celsius = 0
# ══ FAHRENHEIT: two-way view of the underlying celsius ═════
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
# Convert to celsius and reuse the celsius setter — validation reused!
self.celsius = (value - 32) * 5 / 9
# ══ KELVIN: read-only view of the underlying celsius ═══════
@property
def kelvin(self):
return self._celsius + 273.15
t = Temperature(25)
print(f"{t.celsius}°C = {t.fahrenheit}°F = {t.kelvin}K")
# Write to fahrenheit → celsius updates too (two-way sync)
t.fahrenheit = 100
print(f"{t.celsius:.2f}°C = {t.fahrenheit}°F = {t.kelvin:.2f}K")
# Validation is enforced through EITHER path
try:
t.celsius = -300
except ValueError as e:
print("Blocked:", e)
try:
t.fahrenheit = -500 # reuses celsius validation
except ValueError as e:
print("Blocked:", e)
# Read-only kelvin cannot be assigned
try:
t.kelvin = 400
except AttributeError as e:
print("Blocked:", e)
del t.celsius # deleter runs → resets to 0
print(f"After reset: {t.celsius}°C")
Practical Example 2 — BankAccount Balance With Property
class BankAccount:
def __init__(self, holder, opening_balance):
self.holder = holder
self.balance = opening_balance # goes THROUGH the setter
self._log = []
# ── Getter ────────────────────────────────────────────────
@property
def balance(self):
return self._balance
# ── Setter with full validation ───────────────────────────
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError("balance must be numeric")
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = float(value)
# ── Deleter closes the account ────────────────────────────
@balance.deleter
def balance(self):
print(f"Closing {self.holder}'s account, final balance {self._balance}")
del self._balance
# ── Read-only COMPUTED property ───────────────────────────
@property
def is_rich(self):
return self._balance > 100_000
# ── Read-only COMPUTED property ───────────────────────────
@property
def tier(self):
if self._balance >= 1_000_000: return "Platinum"
if self._balance >= 100_000: return "Gold"
if self._balance >= 10_000: return "Silver"
return "Standard"
acc = BankAccount("Alice", 5000)
print(acc.balance, acc.tier, acc.is_rich) # 5000.0 Standard False
acc.balance = 150_000 # setter validates then stores
print(acc.balance, acc.tier, acc.is_rich) # 150000.0 Gold True
# Validation catches junk
try: acc.balance = -100
except ValueError as e: print("Blocked:", e)
try: acc.balance = "a lot"
except TypeError as e: print("Blocked:", e)
# Computed properties are read-only
try: acc.tier = "Platinum"
except AttributeError as e: print("Blocked:", e)
# Deleter runs custom cleanup
del acc.balance
Every line above uses acc.balance or acc.tier as if it were a
plain attribute — no get_balance(), no set_balance(). But
validation, computation, and cleanup all run automatically. That's the uniform
access principle: from the caller's perspective, there's no difference between a
stored value, a computed one, or a validated one. All three look like obj.attr.
Common Mistakes (and Fixes)
| Mistake | What Goes Wrong | Fix |
|---|---|---|
| Storing under the same name as the property | self.age = value inside the setter — infinite recursion, stack overflow |
Use a different name: self._age = value (single underscore convention) |
| Forgetting to write to the underlying attribute | The setter runs, validation passes — but nothing is stored | End the setter with self._x = value |
Using @setter instead of @name.setter |
Python doesn't recognise @setter as a decorator — NameError |
Always use the property's name: @balance.setter |
| Naming the setter differently from the getter | Python treats them as unrelated — no property connection | Setter must have the same name as the getter (they share the property object) |
| Adding a setter after the class body is defined | Redefining the getter but forgetting the setter deletes the setter silently | Define all three (getter, setter, deleter) contiguously in the class body |
| Doing expensive work in a getter | Innocent-looking obj.total triggers a database query on every read |
Cache the result (e.g., functools.cached_property) or make it a method |
Using @property when a method would be clearer |
Callers assume no side effects; you make an API call inside — surprise | If it has side effects or takes arguments, use a plain method, not a property |
Quick Reference
| Task | Syntax | Notes |
|---|---|---|
| Define a getter | @property + def x(self): | Read-only unless a setter follows |
| Define a setter | @x.setter + def x(self, value): | Must match the getter's name |
| Define a deleter | @x.deleter + def x(self): | Runs on del obj.x |
| Store underlying value | self._x = value | Underscore prefix — universal convention |
| Access from caller | obj.x | Same as a plain attribute |
| Trigger the setter | obj.x = value | Just an assignment — setter fires automatically |
| Trigger the deleter | del obj.x | Deleter runs then attribute is gone |
| Read-only property | Only @property, no setter | Assignment raises AttributeError |
| Computed property | Getter returns a value derived from other attrs | No underlying storage needed |
| Expensive computed | @functools.cached_property | Runs once, caches the result |
Golden Rules
obj.x — no parentheses, no method call syntax. From inside, you control what
that means.
@property — without breaking the class's API. That's the
whole point.
self._x, not
self.x. Writing to self.x inside the setter would call the setter
again — infinite recursion, immediate crash.
@x.setter — where x is the getter's function name. Different
names mean two unrelated properties, not a getter/setter pair.
AttributeError. Use this deliberately for values that shouldn't change after
construction — IDs, hashes, derived data.
full_name, fahrenheit)
recompute on every read. That's usually fine. If the computation is expensive, use
@functools.cached_property to memoise the result.
obj.x to be free.
del obj.x
needs custom cleanup — resetting to a default, closing a resource, revoking a token.
Otherwise leave it out.
@property over Java-style get_x()/set_x()
methods. It's more Pythonic, callers get cleaner code, and you keep the freedom to change
internal storage later without breaking anyone.