Why does self.count += 1 on an integer class attribute create an instance attribute?
answer
- It is two operations, not one
- The read and the write use different rules
- The result is stored where nothing was found
- Immutable target means a new object
- Assign on the class to share it
basics
~20 sAugmented assignment is a read then a write. The read falls back to the class attribute, but the write always binds in the instance's own namespace, so the class value never changes and each object ends up with its own counter.
solid answer
~50 s`self.count += 1` expands to roughly "read `self.count`, add one, store back into `self.count`". The read misses the instance `__dict__` and falls back to the class, returning the shared value. Because `int` is immutable there is no in-place update available, so the addition builds a new object. The store is an ordinary attribute assignment on the instance, and assignment **never** walks the MRO - it binds in `vars(self)`, shadowing the class attribute from then on. The class attribute keeps its original value, so a "how many objects have I processed" counter written this way silently becomes a per-object counter that is almost always 1. To increment shared state you must assign on the class: `type(self).count += 1`, or `MyClass.count += 1` when you specifically want the defining class rather than the subclass of the instance.
code
python · 14 linesclass Job:
processed = 0
def run(self):
self.count_it()
def count_it(self):
self.processed += 1 # read from the class, store on self
a, b = Job(), Job()
a.run()
b.run()
print(a.processed, b.processed, Job.processed) # 1 1 0
print(vars(a)) # {'processed': 1}go deeper
Recall that += on an attribute is a read plus a write, and that writes always land on the instance. Recognising the symptom - the class value stays at its initial number - is enough at this level.
Explain the expansion step by step: load through the MRO, compute a new value because int is immutable, store with no lookup at all. Show the fix that assigns on the class and say why it works.
Diagnose the shape in real code: metrics stuck at zero, per-object counts of one, vars(instance) suddenly holding the name. Explain the different behaviour with a mutable attribute and the thread-safety hole in any shared counter.
Own the design call: a counter shared across a class hierarchy needs an explicit owner - the defining class, a module-level object, or a proper metrics facility - and shared mutable class state carries locking and testing costs you should decide about deliberately.
### The expansion An augmented assignment `target += value` is not a single atomic operation. For an attribute target, Python evaluates it as: load the object, load the attribute, apply the in-place operator, then **store the result back into the same attribute target**. The store is a genuine `STORE_ATTR` - the same bytecode that `self.count = x` produces. That two-step shape collides with the read/write asymmetry of attribute access. The *load* uses the full lookup: instance `__dict__` first, then the classes in `type(self).__mro__`. The *store* uses no lookup at all: it binds in the instance's own `__dict__`. So a class attribute can be read and then overwritten in the instance, all inside one innocuous-looking line. ### Why `int` makes it total Integers are immutable and do not implement in-place addition, so the `+=` falls back to ordinary addition and produces a brand-new `int`. The class attribute object is not touched in any way; it is merely read. The store then plants the new value on the instance. The consequence in a counter written as a class attribute is that every instance ends up with its own copy of the count, generally stuck at 1 or at however many times that one object incremented. Aggregate reporting off `MyClass.count` shows zero, because the class attribute was never written. This is a common source of "my metrics are always zero" confusion, and the class attribute holding a stale initial value is the tell. ### The mutable case behaves differently - and worse If the class attribute is a list, `self.items += [x]` calls the list's in-place add, which mutates the shared list and returns it, and then stores that same object on the instance. You get both outcomes: the shared class list really did grow, so every other instance sees the new element, *and* the instance now has its own binding pointing at that same list. `vars(self)` is no longer empty, which makes the shared-mutation bug look, on inspection, as though it had been fixed. Knowing that `+=` on a mutable class attribute does both things is what separates a memorised rule from an understood one. ### Doing it on purpose When you genuinely want a shared counter, assign on a class object rather than on the instance: ```python class Job: processed = 0 def run(self): type(self).processed += 1 ``` `type(self).processed += 1` reads through the MRO and stores on the instance's actual class. That distinction matters under inheritance: if `Batch` subclasses `Job` and has no `processed` of its own, the read finds `Job.processed` but the store creates a **new** `Batch.processed`, so the subclass starts its own tally from the parent's current value and the parent's counter stops moving. If you want one number for the whole hierarchy, name the defining class explicitly - `Job.processed += 1` - so both the read and the store target the same class object. Choosing between those two is a real design decision, not a detail. Alternatives worth naming: keep the counter on a module-level object or a dedicated singleton, or make it a `classmethod` that owns the increment so the choice is written down once. And note that none of these is thread-safe - `+=` on a class attribute is a read, an add and a store, and two threads can interleave between them, so a counter shared across threads needs a lock or an atomic primitive regardless of where it lives. ### Related surprises with the same cause The same read-then-store shape explains several neighbours. `self.flags = self.flags | 1` behaves identically because it is the same two steps written out. A method that does `self.config["k"] = v` on a class-level dict mutates the shared dict and creates no instance attribute at all, because there is no attribute store in that line - the store is into the dict, not into `self`. Tracing exactly which operation is a mutation and which is a binding is the whole skill here. ### How to confirm it quickly After the increment, check `vars(instance)`: if it now contains the attribute, the store landed on the instance. Compare with the class value through `vars(type(instance))` or by naming the class directly. The pair of values - instance 1, class 0 - is the unambiguous signature of this bug.
- What is different when the class attribute is a list and the method does `self.items += [x]`?Both effects happen. The in-place add mutates the shared class list, so every other instance sees the new element, and the augmented assignment then stores that same list object in the instance `__dict__`. The instance now owns a binding, which makes `vars(self)` look healthy while the shared list is still growing for everyone.
- Why might `type(self).processed += 1` still give surprising numbers under inheritance?Because the read walks the MRO but the store does not. If a subclass has no counter of its own, the read finds the parent's value and the store creates a fresh attribute on the subclass, seeded from that value. The subclass then tallies separately and the parent's counter stops advancing. Name the defining class explicitly when you want one number for the hierarchy.
- Is incrementing a class attribute safe from multiple threads?No. `+=` on an attribute is a read, an addition and a store, and a thread switch can land between any two of them, so increments are lost. Guard it with a `threading.Lock`, or use a design that does not require a shared mutable counter. Where the attribute lives makes no difference to this.
saying these in an interview costs you the question
- Believes += mutates the class attribute in place
- Thinks attribute assignment searches the MRO
- Cannot explain why the class value stays unchanged
- Claims the instance attribute existed all along
- Says the fix is to read through the instance instead
- Assumes a class-level counter is thread-safe