Worked examples — Properties — `@property`, `@setter`, `@deleter` for controlled access
The scenario matrix
Every property you will ever write lands in one of these cells. We cover them all.
| # | Cell (case class) | What it stresses | Example that hits it |
|---|---|---|---|
| A | Pure read (@property only) |
Getter returns stored value; write is blocked | Ex 1 |
| B | Screened write (@setter with validation) |
Accept good values, reject bad ones | Ex 2 |
| C | Boundary / limiting value | The exact edge (== the limit) — accept or reject? |
Ex 2 (the -273.15 edge) |
| D | Zero / degenerate input | Empty string, 0, None, negative |
Ex 3 |
| E | Computed, read-only | No stored field at all — value derived on the fly | Ex 4 |
| F | Cached + invalidation | Compute once, but stay consistent when source changes | Ex 5 |
| G | Controlled delete (@deleter) |
del obj.x runs cleanup, not a blind wipe |
Ex 6 |
| H | The recursion trap | Setter that accidentally calls itself | Ex 7 |
| I | Real-world word problem | Multiple properties keeping an invariant together | Ex 8 |
| J | Exam-style twist | Descriptor-lookup order: property vs instance __dict__ |
Ex 9 |
Prerequisite ideas we lean on: Encapsulation and Access Modifiers, Invariants and Class Design, Descriptors __get__ __set__ __delete__, Class vs Instance Attributes.
Ex 1 — Pure read (cell A)
Forecast: Guess — after p = Person("Ada"), what does p.name = "Bob" do? (Hold your guess.)
Steps.
- Store the real data in a backing field
self._name.- Why this step? The leading underscore is the convention for "internal, hands off." Keeping the public name (
name) separate from the storage name (_name) is what lets the property intercept access.
- Why this step? The leading underscore is the convention for "internal, hands off." Keeping the public name (
- Define a getter with
@property, returningself._name.- Why this step?
@propertyturns the method into a read-accessor:p.namenow runs this function instead of returning a stored object.
- Why this step?
- Define no setter.
- Why this step? With no
fsetfilled in, Python has nothing to run forp.name = ..., so it raisesAttributeError. That is exactly our "read-only to the outside" rule.
- Why this step? With no
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
p = Person("Ada")
p.name # "Ada"
p.name = "Bob" # AttributeError: property 'name' has no setterEx 2 — Screened write + the exact boundary (cells B & C)
Forecast: Should t.celsius = -273.15 succeed or fail? What about -273.16?
Steps.
- Getter returns
self._celsius; setter takesvalue.- Why this step? We need a place to screen the value before it lands in storage — that place is
fset.
- Why this step? We need a place to screen the value before it lands in storage — that place is
- Guard:
if value < -273.15: raise ValueError.- Why this step? We use strict
<(not<=) because the limit itself is a valid physical temperature. Choosing the comparison operator is the boundary decision — this is cell C.
- Why this step? We use strict
- Only after passing the guard, store
self._celsius = value.- Why this step? Storing last guarantees a rejected value never corrupts the object.
class Temperature:
def __init__(self, c):
self.celsius = c # goes THROUGH the setter
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = valueEx 3 — Zero / degenerate inputs (cell D)
Forecast: Which of these four gets through? "ada", "", None, 0.
Steps.
- First check the type:
if not isinstance(value, str).- Why this step?
0andNoneare not strings. Checking type first stops us from calling string methods on a non-string (which would crash for a confusing reason instead of a clear one).
- Why this step?
- Then check emptiness:
if not value.strip().- Why this step?
""and" "(spaces only) are degenerate — technically strings but useless as names..strip()collapses whitespace-only to empty.
- Why this step?
- Store the cleaned value
value.strip().- Why this step? Storing the trimmed form keeps the invariant "no leading/trailing spaces" true forever.
class Account:
@property
def username(self):
return self._username
@username.setter
def username(self, value):
if not isinstance(value, str):
raise TypeError("username must be a string")
if not value.strip():
raise ValueError("username cannot be empty")
self._username = value.strip()Ex 4 — Computed, read-only (no stored field) (cell E)
The figure below shows a mint-filled circle. A lavender arrow runs from the centre outward — that is the radius r, the only number we store. A coral double-arrow stretches all the way across — that is the diameter, and you can see by eye it is exactly two radii long, so diameter = 2r. The mint shading is the area pi r^2. Nothing on this picture is stored except the lavender radius; the coral diameter and the mint area are drawn fresh from r — exactly what "computed property" means.

Forecast: After c = Circle(2), if you write c.diameter = 10, does r change to 5? Guess.
Steps.
- Store only the primitive
self._r.- Why this step? Radius is the single source of truth; everything else is derived. Storing derived values would risk them going out of sync (see Ex 5).
diameterreturns2 * self._r;areareturns .- Why this step? These are pure functions of
r. Look at the figure: the diameter (coral) is just twice the radius (lavender), and the area (mint fill) is . No storage needed.
- Why this step? These are pure functions of
- Give neither a setter.
- Why this step? Setting
diameter = 10is ambiguous — shouldrbecome 5? We refuse to guess; the property is read-only, so it raisesAttributeError.
- Why this step? Setting
import math
class Circle:
def __init__(self, r):
self._r = r
@property
def diameter(self):
return 2 * self._r
@property
def area(self):
return math.pi * self._r ** 2Ex 5 — Cache with invalidation (cell F)
The figure below is the decision flow. A butter box (call c.area) leads into a lavender diamond asking _area is None?. On yes you take the coral SLOW path: compute pi r^2 and store it. On no you take the mint FAST path: just hand back the cached number. The dotted arrow at the bottom is the crucial bit — the radius setter (coral) reaches up and sets _area = None again, which forces the next read back onto the slow path so it recomputes. That dotted arrow is cache invalidation.

Forecast: Read the area for r=2, then set c.radius = 3. Does the next c.area show the new area or the stale one?
Steps.
- In
__init__, set the sentinelself._area = Nonealongsideself._r.- Why this step? The cache needs a "not computed yet" marker before any read happens. We choose
Noneas that sentinel because a real area is a positive number, soNonecan never be mistaken for a valid cached value. Without this line, the very firstc.areawould hitif self._area is Nonewith_areaundefined →AttributeError.
- Why this step? The cache needs a "not computed yet" marker before any read happens. We choose
areagetter: ifself._area is None, compute and store it; else return the cached value.- Why this step? The
Nonesentinel means "not computed yet." Look at the figure — the first call takes the slow path (compute), later calls take the fast path (return cache).
- Why this step? The
radiussetter setsself._area = None.- Why this step? This is the whole point of cell F. Changing the source invalidates the cache (the dotted arrow in the figure). Without this line,
areawould keep returning the old value forever — a silent bug.
- Why this step? This is the whole point of cell F. Changing the source invalidates the cache (the dotted arrow in the figure). Without this line,
radiusgetter returnsself._r.- Why this step? We still want normal read access to the radius.
import math
class Circle:
def __init__(self, r):
self._r = r
self._area = None # sentinel: "not computed yet"
@property
def area(self):
if self._area is None:
self._area = math.pi * self._r ** 2
return self._area
@property
def radius(self):
return self._r
@radius.setter
def radius(self, v):
self._r = v
self._area = None # invalidate!
c = Circle(2)
c.area # computes -> ~12.566
c.radius = 3
c.area # recomputes -> ~28.274Recall When to reach for the built-in instead
Python ships functools.cached_property for exactly the compute-once case — but it does not auto-invalidate. If your source can change, you still write the manual setter shown here.
Ex 6 — Controlled delete (cell G)
Forecast: After del f.path, is f.path gone (AttributeError) or None?
Steps.
- In
__init__, store the path in the backing fieldself._path = path.- Why this step? The property's getter, setter, and deleter all read and write
self._path. If__init__did not create it first, the very firstf.pathread would find no_pathattribute →AttributeError. The backing field must exist before any accessor runs.
- Why this step? The property's getter, setter, and deleter all read and write
- Getter returns
self._path; setter stores intoself._path.- Why this step? Normal read/write of the path, both routed through the single backing field.
@path.deleterruns cleanup and setsself._path = None.- Why this step?
del f.pathnow routes into our function. We turn a destructive operation into a controlled decommission — the guard's "Delete" duty. We reset toNone(notdel self._path) so the getter still works afterwards.
- Why this step?
class TempFile:
def __init__(self, path):
self._path = path
@property
def path(self):
return self._path
@path.setter
def path(self, v):
self._path = v
@path.deleter
def path(self):
print("cleaning up", self._path)
self._path = None
f = TempFile("/tmp/x")
del f.path # prints "cleaning up /tmp/x"
f.path # None (attribute still exists!)Ex 7 — The recursion trap (cell H)
Forecast: RecursionError, AttributeError, or works fine?
Steps.
- Trace one assignment
obj.value = 5.- Why this step?
obj.value = 5triggers the setter. Inside,self.value = valueis itself an assignment tovalue→ triggers the setter again → and again…
- Why this step?
- Count the depth.
- Why this step? Each call adds a stack frame; Python halts at its recursion limit and raises
RecursionError.
- Why this step? Each call adds a stack frame; Python halts at its recursion limit and raises
- Fix: assign to the different backing name
self._value.- Why this step?
_valueis a plain instance attribute with no property, so its assignment does not re-enter the setter. The loop is broken.
- Why this step?
class Broken:
@property
def value(self):
return self._value
@value.setter
def value(self, v):
self.value = v # 😱 calls the setter again -> RecursionErrorEx 8 — Real-world word problem (cell I)
Forecast: Start with 2 × 3 (area 6). Set width = 5. What is the new area?
Steps.
- In
__init__, assign through the property setters:self.width = wandself.height = h.- Why this step? Routing construction through the setters means the positivity check runs from birth — you can never build a rectangle with a bad side. This is the same "validate from construction" idea as Ex 2's temperature.
- Screen both
widthandheightin their setters: rejectvalue <= 0.- Why this step? A rectangle with a non-positive side is degenerate; blocking it upholds the invariant "sides are real lengths." (See Invariants and Class Design.)
- Make
areaa computed property:self._w * self._h.- Why this step? Because area is derived, it can never disagree with the sides — it's recomputed on every read. This sidesteps the whole "stale data" problem for free.
- No setter on
area.- Why this step? Setting area is ambiguous (which side changes?), so it stays read-only — same reasoning as Ex 4.
class Rectangle:
def __init__(self, w, h):
self.width = w
self.height = h
@property
def width(self):
return self._w
@width.setter
def width(self, v):
if v <= 0:
raise ValueError("width must be positive")
self._w = v
@property
def height(self):
return self._h
@height.setter
def height(self, v):
if v <= 0:
raise ValueError("height must be positive")
self._h = v
@property
def area(self):
return self._w * self._h
r = Rectangle(2, 3)
r.area # 6
r.width = 5
r.area # 15 (recomputed, invariant held)
r.width = 0 # ValueError: width must be positiveEx 9 — Exam-style twist: property vs instance dict (cell J)
The figure below stacks the five attribute-lookup steps as a ladder. Reading c.x starts at the top butter box (search the class for the name x). It reaches the mint box at step 2: "is this a data descriptor?" — and a property, because it defines __set__, is a data descriptor, so lookup stops right here and returns 42. The coral box at step 3 (the instance __dict__['x'] = 999) sits below step 2, so it is never reached. That ordering is the whole guarantee.

__dict__ at step 3, so the property always wins.
Forecast: Does c.x return 42 or 999? Commit before reading on.
Steps.
- Recall the lookup order from the parent note: class MRO → data descriptor → instance
__dict__→ non-data descriptor →AttributeError.- Why this step? The order is the entire answer. Because a property defines
__set__/__delete__, it is a data descriptor, so it is checked before the instance__dict__. (See Descriptors __get__ __set__ __delete__.)
- Why this step? The order is the entire answer. Because a property defines
- Force a value into the instance dict with
c.__dict__['x'] = 999, bypassing normal assignment.- Why this step? We cannot use
c.x = 999, because that would run the property machinery (and here there's no setter, so it would raiseAttributeError). Writing straight into__dict__is the only way to smuggle a same-named entry in — the sneakiest attack a property must survive.
- Why this step? We cannot use
- Read
c.xand see which layer answers.- Why this step? Python finds the property on the class at step 2, calls its
__get__, returns42, and never descends to the dict entry at step 3. Look at the figure: step 2 sits above step 3.
- Why this step? Python finds the property on the class at step 2, calls its
class C:
@property
def x(self):
return 42
c = C()
c.__dict__['x'] = 999 # sneak a value into the instance dict
c.x # 42 -- the property (data descriptor) wins!
c.__dict__['x'] # 999 -- the smuggled value is still sitting there, just ignoredRecall Contrast with Java
In Getters and Setters in Java vs Python, Java can't do this "upgrade a field into a guarded accessor without changing callers" trick — you commit to getX()/setX() up front. Python's descriptor protocol is what makes the seamless upgrade possible.
Flashcards
Why use strict < not <= when rejecting temperatures below −273.15?
<= would wrongly reject it.In Ex 5, what single line prevents the cached area from becoming stale?
self._area = None inside the radius setter (cache invalidation).Why does c.x return 42 even when c.__dict__['x'] is 999?
Would a non-data descriptor (only __get__) also beat an instance dict entry of the same name?
__set__) wins.What crashes if a setter does self.value = v instead of self._value = v?
After del f.path with a @path.deleter that sets _path=None, what is f.path?
Why give area no setter in the Rectangle example?
area == w*h automatically true.