skip to content

A descriptor instance is shared by every object of its class, so where should per-instance state live?

level: seniorimportance: should knowfreq 34%

answer

  1. The class body runs once
  2. self identifies the attribute, not the object
  3. One write shows up everywhere
  4. Store it with the object it describes
  5. Slotted owners need a weak-keyed map

basics

~20 s

The descriptor is created once, in the class body, so anything stored on self is shared by all instances. Keep per-instance values in the managed object's own dict, or in a weak-keyed mapping when the class has no dictionary.

solid answer

~50 s

A descriptor object is built once when the class body executes, so `self` inside `__get__`/`__set__` is **per descriptor, per owning class** — never per managed instance. Storing a value on `self` makes every object of that class share it, which shows up as one object's write appearing on all the others. The standard fix is for `__set__` to write into `obj.__dict__` under a private key and `__get__` to read it back; because the descriptor defines `__set__` it is a data descriptor, so it still wins the lookup even though the instance dictionary now holds the same-named key. If the owner class defines `__slots__` and therefore has no `__dict__`, use a `weakref.WeakKeyDictionary` keyed on the instance instead — accepting that the instances must be hashable and weak-referenceable. And remember the shared descriptor is also shared across threads, so any state you do keep on it needs its own synchronisation.

code

python · 16 lines
python
class Celsius:
    def __get__(self, obj, objtype=None):
        return self.value

    def __set__(self, obj, value):
        self.value = float(value)


class Sensor:
    temp = Celsius()


a, b = Sensor(), Sensor()
a.temp = 21.0
b.temp = 92.0
print(a.temp)   # 92.0 - one shared slot, last write wins

go deeper

for a junior

Recall the single fact that drives everything here: the descriptor is created once when the class body runs, so it is shared by every instance of that class and by its subclasses.

for a middle

Explain the mechanics of the fix: set writes into obj.dict under a private key, get reads it back, and the descriptor keeps control because defining set makes it a data descriptor.

for a senior

Diagnose it from symptoms. Values smearing between objects, a counter that grows too fast, or two attributes reading the same number should send you straight to state kept on the shared descriptor, and you should know the slots and weak-reference constraints on the alternatives.

for a principal

Own the reusability and safety bar for shared machinery: descriptors are process-wide singletons, so a library-grade one must derive unique storage per attribute, work for slotted owners, and treat any state it does keep as shared mutable state under free-threading.

### Where the object actually lives ```python class Sensor: temp = Celsius() ``` `Celsius()` is evaluated **once**, while the class body runs. One object is created, it is stored in `Sensor.__dict__['temp']`, and every `Sensor` instance that ever exists reaches that same object. Subclasses inherit the very same object unless they rebind the name. So `self` inside its methods identifies the *attribute*, not the object whose attribute is being read. ### The bug this causes ```python class Celsius: def __get__(self, obj, objtype=None): return self.value # shared by every Sensor def __set__(self, obj, value): self.value = float(value) a, b = Sensor(), Sensor() a.temp = 21.0 b.temp = 92.0 a.temp # 92.0 - b's write landed on a ``` In a sensor-telemetry collector this is a nasty failure because it is silent and load-dependent: with one device in the test rig nothing looks wrong, and only once several collectors run concurrently do readings start to smear across devices. Each `__set__` overwrites the single shared slot, so what any object reports is whatever was written last, anywhere in the process. The same shape appears in cache-hit counters and validation bookkeeping kept on the descriptor: with one object per class it looks fine and it never is. ### Fix one: the instance dictionary ```python class Celsius: def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__["_temp"] def __set__(self, obj, value): obj.__dict__["_temp"] = float(value) def __delete__(self, obj): obj.__dict__.pop("_temp", None) ``` Now the value lives with the object it describes, dies with it, is visible to `vars(obj)`, pickles with the instance and costs nothing extra. The key subtlety is that this only works cleanly for a **data** descriptor: because the class defines `__set__`, `__get__` is still consulted before the instance dictionary, so it retains control even though the data now sits there. A *non-data* descriptor that wrote under its own attribute name would be shadowed from the second read onward — which is a deliberate caching technique, not this pattern. Hardcoding a key like `"_temp"` is only acceptable for a one-off; a reusable descriptor must derive the key from the attribute name it was assigned to, which is what the name-capture hook fired at class creation exists for. Whatever key you choose, it must be **unique per descriptor**, or two attributes managed by the same descriptor class collide on one storage slot — a second, quieter version of the same bug. And the key must not be one you also expose as a public attribute, or callers can bypass validation by writing to it directly. Write `obj.__dict__[key] = ...` rather than `setattr(obj, key, ...)`: if the key ever equals the managed name, `setattr` re-enters `__set__` and recurses until the stack blows. ### Fix two: a weak-keyed side table If the owner class defines `__slots__` there is no instance dictionary to write into, and the same is true for many C-implemented types. Then use `weakref.WeakKeyDictionary` keyed on the instance, so entries disappear when the object does. The constraints are real: the instances must be hashable and weak-referenceable, and a class that defines `__eq__` without `__hash__` is unhashable, so this silently excludes exactly the value-like classes people most want to attach descriptors to. It is also slower than a dictionary write and, under free-threading, needs the same care as any other shared mapping. A third option exists for slotted classes: give the descriptor a *companion slot* in the owner's `__slots__` and store through it. That trades generality for speed and keeps everything on the instance. ### The concurrency angle Because one descriptor object serves the whole process, any mutable state you deliberately keep on it — counters, caches, a lazily built converter table — is shared across every thread touching any instance. Under the free-threaded build officially supported since 3.14, that state has no GIL to serialise updates, so read-modify-write bookkeeping on a descriptor needs its own lock. Per-instance storage sidesteps the question for the values themselves, which is one more reason to prefer it. ### How to spot it in review Any assignment to `self.<something>` inside `__set__` or `__get__` that is not read-only configuration set up in `__init__` is the smell. Configuration — a limit, a unit, a validator function — is exactly what *should* live on the descriptor, because it genuinely is per attribute. Values are exactly what should not.

  • Why write obj.__dict__[key] directly instead of calling setattr on the instance?
    Because setattr goes back through object.__setattr__, which finds the same data descriptor and calls __set__ again. If the storage key ever coincides with the managed attribute name that is infinite recursion ending in a RecursionError. Writing straight into obj.__dict__ bypasses the protocol and is the documented way to store a descriptor's per-instance value.
  • What breaks this pattern when the owner class defines __slots__?
    Slotted instances have no __dict__ to write into, so the assignment raises AttributeError. The alternatives are a weakref.WeakKeyDictionary keyed on the instance, which needs the instances to be hashable and weak-referenceable, or giving the descriptor a companion slot declared in the owner's __slots__ and storing through that.
  • Is there any state that legitimately belongs on the descriptor object itself?
    Yes: per-attribute configuration. A bound, a unit, a converter, a validator or the captured attribute name are properties of the attribute, not of any instance, and every object should see the same value. The rule is that configuration set up once belongs on the descriptor and anything that varies per managed object does not.
  • Two attributes on one class use the same descriptor class. What goes wrong with a hardcoded storage key?
    They collide. Both descriptors write the same key into the same instance __dict__, so the second attribute overwrites the first and both read back one value. The storage key must be derived per descriptor, which is what capturing the assigned attribute name at class creation is for.

The descriptor is a single clipboard nailed to the wall of the room, not one clipboard per visitor: write a reading on it and the next person through the door sees yours.

saying these in an interview costs you the question

  • Stores the managed value on the descriptor's self
  • Thinks one descriptor object is created per instance
  • Uses setattr with the managed name inside __set__
  • Hardcodes one storage key for a reusable descriptor
  • Assumes __slots__ owners still have an instance __dict__
  • Ignores that descriptor state is shared across threads

context