2.1.7 · D5OOP Fundamentals

Question bank — Properties — `@property`, `@setter`, `@deleter` for controlled access

2,168 words10 min readBack to topic

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.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access

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 = value
What 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._area
The 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 = v
Why 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 setter
The 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 anywhere
What happens at class creation?
NameErrorx.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 = v
Why 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."