2.1.7 · D5OOP Fundamentals
Question bank — Properties — `@property`, `@setter`, `@deleter` for controlled access
Before you start — the vocabulary you'll need
The traps below reuse five ideas constantly. Read this recap once so nothing is undefined when you meet it.


Prerequisite topics for a deeper dive: Encapsulation and Access Modifiers (why we hide fields at all), Descriptors __get__ __set__ __delete__ (the general mechanism under properties), Class vs Instance Attributes (why class-level vs instance-level matters here), and Invariants and Class Design (the rules setters protect).
True or false — justify
A property object lives on the instance, not the class.
False. The
property is a class-level attribute; only the backing data (like _x) lives per-instance. That is exactly why every instance shares one gatekeeper — see step 1 of the lookup pipeline, which searches the class chain.Accessing obj.x when x is a property always runs fget, even if x is also in obj.__dict__.
True. A property defines
__set__/__delete__, making it a data descriptor, so it is caught at step 2 of the pipeline, which runs before the instance-dict check at step 3.A @property with no @setter makes the underlying data immutable.
False. It only blocks
obj.x = ... from outside (the fset slot is empty). Internal code can still mutate the backing field self._x freely — read-only is about the outside door, not the storage.@property and plain def get_x(self) are basically the same thing to a caller.
False. The caller writes
obj.x (no parentheses) versus obj.get_x(). That syntactic difference is the whole point — it upholds the Uniform Access Principle.You must define the getter before the setter.
True (with the
@x.setter sugar). @x.setter needs an existing property named x to copy and add fset to. Without @property first, the name x is not a property object.A property can be attached at the instance level for just one object.
False. Descriptor magic only fires at step 1–2, which search the class. Putting a property in
obj.__dict__ makes it ordinary stored data returned at step 3 — its __get__ never runs.Deleting a property with del obj.x removes the property from the class.
False. It calls
fdel(obj) (the @deleter logic) for that instance. The property object stays on the class, ready for the next access.A computed property like fahrenheit uses zero extra storage.
True. Its
fget derives the value on every read from _celsius; there is no _fahrenheit field. That is the memory win of computing-on-access.A property works fine on a class that defines __slots__.
True — as long as the backing field is also slotted or is the property name's own storage. Slotted classes forbid arbitrary instance-dict entries, but the property itself lives on the class (unaffected), and you just need a slot like
_x for the backing data.Spot the error
@celsius.setter
def celsius(self, value):
self.celsius = valueWhat breaks, and why does the underscore fix it?
self.celsius = value hits step 2 of the lookup and re-invokes this same fset → infinite recursion → RecursionError. Storing to self._celsius writes a name with no data descriptor, so it falls straight to step 3 (plain instance-dict store) and the setter is never re-entered.@property
def area(self):
return self._areaThe class caches _area, but radius has a setter that only does self._r = v. What is wrong?
The setter forgot to invalidate the cache (
self._area = None). After changing radius, area's fget returns a stale value computed from the old radius.class C:
@property
def x(self): return self._x
@property # <- second @property, not @x.setter
def x(self, v): self._x = vWhy is this broken?
The second
@property replaces the first, creating a brand-new read-only property (its fset slot is empty). You needed @x.setter to add fset to the existing property, not overwrite the whole box.def __init__(self, c):
self._celsius = c # bypasses the setterThe setter validates value < -273.15. What invariant risk appears?
Construction bypasses validation.
Temperature(-500) is accepted silently because writing _celsius skips the setter. Assigning self.celsius = c (through the property) is what runs the guard from birth.@x.setter
def x(self, value):
self._x = value
# ...no @property def x anywhereWhat happens at class creation?
NameError — x.setter needs an existing x property to copy. Without @property defining x first, the name x is not a property object.A colleague writes obj.balance = 50 on a read-only property and expects it to silently do nothing.
Wrong. It raises
AttributeError: can't set attribute because the fset slot is empty. Read-only does not mean "ignore writes"; it means "refuse writes loudly", which protects the invariant.Why questions
Why does the backing field use a leading underscore (_x) instead of x?
_x is a different name with no data descriptor on the class, so writing it lands at step 3 (plain store) and never re-triggers the setter. The underscore also signals "private, don't touch directly" by convention.Why does a property override an instance __dict__ entry of the same name, but a plain function attribute would not?
The property is a data descriptor (
__set__ present) → caught at step 2, above the instance dict at step 3. A plain function is only a non-data descriptor, checked at step 4, so it loses to the instance dict.Why prefer starting with a plain attribute and upgrading to a property later, over writing getters/setters upfront?
Because upgrading a plain
obj.x to a property does not change any call site — callers still write obj.x. Getter/setter methods would force every caller to change to obj.get_x().Why does making fahrenheit read-only (no setter) make sense?
It is derived from
celsius. Allowing obj.fahrenheit = 100 would be ambiguous — should it silently change celsius? Leaving fset empty keeps a single source of truth.Why is validation inside a setter better than validating everywhere the value is used?
The setter is the single choke point — every write funnels through
fset, so the invariant is enforced once, not re-checked at every read site (which you would inevitably forget somewhere).Why can't you just make balance a normal attribute if you want it "mostly" read-only?
A normal attribute has no
fset guard — anyone can write acc.balance = -9999, silently destroying the invariant. Only the property intercepts the write at step 2.Concrete pattern — override only the setter in a subclass
class Base:
@property
def x(self):
return self._x
@x.setter
def x(self, v):
self._x = v
class Sub(Base):
# reuse Base's getter (fget), replace only the setter
@Base.x.setter
def x(self, v):
if v < 0:
raise ValueError("no negatives")
self._x = vWhy does @Base.x.setter work here?
Base.x is the parent's property object; calling .setter(func) returns a copy keeping the parent's fget but with the new fset, which we bind to x in Sub. So the getter is inherited unchanged and only the setter's rule tightens.What would go wrong if Sub wrote a lone @x.setter?
NameError — there is no x in Sub's namespace yet to attach a setter to. You must reference the parent's property (@Base.x.setter) or redefine the whole property.Edge cases
If you define @property but never access obj.x, does fget run?
No. Descriptors are lazy:
__get__ only fires on actual attribute access. Merely defining the property costs nothing at read time.del obj.x when the property has a getter and setter but no @deleter — what happens?
AttributeError: can't delete attribute. Deletion is only allowed if fdel is defined; an empty deleter slot keeps the delete door locked.A property whose getter has a side effect (prints, logs, computes) — how many times does obj.x + obj.x run it?
Twice. Each
obj.x is a separate access, each firing fget. If the computation is expensive, that is a reason to cache (or use functools.cached_property).What does Circle.area (accessed on the class, not an instance) return?
The
property object itself, not a computed number — because __get__ receives None for the instance. To compute you need a real instance: Circle(5).area.Setting the source value (radius) after area was already cached — is the old area returned next time?
Only if the setter forgets to reset the cache. A correct setter sets
self._area = None, forcing recomputation on the next area read. This is the cache-invalidation hook.Does a property play nicely with __slots__?
Yes. The property lives on the class, so it is untouched by slots. But because a slotted class has no free
__dict__, your backing field (e.g. _x) must itself be declared in __slots__, else assigning it raises AttributeError.When does __set_name__ fire for a descriptor, and why care?
At class creation, once, when the descriptor is bound to a name — it tells a custom descriptor its own attribute name. Properties don't rely on it (you name the backing field yourself), but it explains how general descriptors auto-discover their key.
Is @property the only way to get controlled access?
No. A full [[Descriptors get set delete|descriptor]] class is the general mechanism;
property is just a ready-made, single-attribute convenience built on top of it.Recall One-line self-test
The property is a class-level data descriptor; the value it guards is per-instance; reads hit step 2 of lookup (above the instance dict at step 3); the setter is the single fset choke point for invariants; and the golden rule is "underscore so it doesn't recurse."