Why does a mutable list defined in a Python class body end up shared by every instance?
answer
- The class body runs only once
- One object, many instances pointing at it
- Appending is not assigning
- No instance entry is ever created
- Bind a fresh container in __init__
basics
~20 sThe list is built once, when the class body runs, and stored on the class. Every instance reaches that same object through self.name, so an append from one is visible from all. Build the list in init instead.
solid answer
~50 sThe class body executes once at import time, so `unmatched = []` creates exactly one list object and binds it in the class's `__dict__`. When an instance evaluates `self.unmatched`, nothing is found in its own `__dict__`, so the lookup falls back to the class and returns that shared list. `self.unmatched.append(row)` is a **mutation**, not an assignment - it never creates an instance attribute, so every object keeps mutating the same list and the data accumulates across all of them for the life of the process. In a long-running reconciliation job that stashes unmatched rows this way, the list is never released between runs and the working set climbs until the process is restarted. The fix is to bind a fresh object per instance: `self.unmatched = []` in `__init__`. Keep class-body attributes immutable when they are meant as defaults.
code
python · 13 linesclass Reconciler:
unmatched = [] # built once, lives on the class
def record(self, txn):
self.unmatched.append(txn) # mutation, not assignment
a, b = Reconciler(), Reconciler()
a.record("txn-1")
b.record("txn-2")
print(a.unmatched) # ['txn-1', 'txn-2']
print(a.unmatched is b.unmatched) # True
print(vars(a)) # {} - a owns nothinggo deeper
Recall the symptom and the fix: a list written in the class body is one list for everybody, so build containers with self.name = [] inside init instead. Be able to point at the shared line in a snippet.
Explain the mechanism precisely - the class body runs once, the attribute lookup falls back to the class, and append mutates rather than assigns, so no instance entry is ever created. Contrast it with a rebinding assignment.
Show how you would diagnose it in production: unbounded growth that a restart clears, empty vars(instance), and an identity check across two objects. Then talk about where deliberate shared class state is legitimate and how you make it obvious.
Own the prevention story rather than the fix: linting for mutable class-body defaults, a convention that shared state is touched through the class name, and awareness that class-level containers are shared across every thread in the process.
### What actually happens A class body is ordinary code that runs exactly once, when the `class` statement is executed at import time. The names it binds become the class's namespace. So writing ```python class Reconciler: unmatched = [] ``` evaluates the display `[]` **once**, producing a single list object, and binds it in `vars(Reconciler)`. It is not a template, not a declaration and not a per-instance initialiser - Python has no concept of "this expression is re-evaluated for each object". Now consider an instance calling `self.unmatched.append(row)`. That line does three things in order: look up `self.unmatched`, get the `append` method of whatever came back, call it. The lookup finds nothing in the instance's own `__dict__`, falls back to the classes in `type(self).__mro__`, and returns the one list on the class. `append` then mutates that object in place. **No assignment happened**, so no instance attribute was ever created - `vars(instance)` stays empty, and every other instance, present and future, sees the appended row. ### Why this survives review The bug is invisible in the mutating method, because `self.unmatched.append(...)` reads exactly like per-object state. It is also invisible in a unit test that constructs one object, exercises it, and asserts on it - the shared list happens to be empty at the start of the first test and the assertions pass. It shows up as cross-contamination the moment two objects live at once, and as unbounded growth the moment the process is long-lived. A concrete shape: a payment reconciliation job creates one `Reconciler` per batch and appends every unmatched transaction to `self.unmatched` for the report. Each batch's report is correct only for the first batch; from then on each report also contains every earlier batch's rows. Worse, nothing ever drops those rows - the class object lives as long as the module, so the list is reachable forever and the process's working set grows monotonically, past 2.4 GB in a job that runs all night. Restarting the process "fixes" it, which is exactly the signature that sends people hunting for a leak in the wrong place. ### Mutation versus rebinding The distinction that explains the whole thing is mutation versus rebinding. * `self.unmatched.append(row)` - mutates the shared object. Nothing is written to the instance. * `self.unmatched = []` - rebinds. It stores a new list in the instance's `__dict__`, which from then on shadows the class attribute for that one object. * `self.unmatched += [row]` - the confusing middle case. The augmented assignment mutates the shared list in place *and then* stores that same object in the instance `__dict__`, so you get both effects at once: the shared list still grew, and the instance now holds a reference to it. This is why an immutable class attribute is safe. `retries = 3` cannot be mutated; any attempt to "change" it through an instance is necessarily an assignment, which shadows cleanly and leaves other instances alone. The danger is confined to mutable containers and mutable objects - lists, dicts, sets, and any custom object with mutating methods. ### The fix, and the alternatives The fix is to create the object per instance: ```python class Reconciler: def __init__(self): self.unmatched = [] ``` If you want a declarative class-body default, use an immutable one and copy it, or use a dataclass field factory - the underlying rule is the same: whatever creates the object must run per instance, not once per class. Deliberately shared mutable class state is occasionally what you want - a registry of subclasses, a process-wide cache - but then it should be named and documented as class state and touched through the class (`Reconciler.registry`), never through `self`, so no reader mistakes it for per-object data. Add thread-safety to that decision too: a shared class-level container is shared across every thread in the process. ### Spotting it Three quick checks: `vars(instance)` is empty where you expected state; `a.unmatched is b.unmatched` is `True` for two independent objects; and `"unmatched" in vars(type(a))` is `True`. Static analysers and third-party linters flag mutable class-body defaults, and a review habit of asking "does this class body build a container?" catches it before it ships.
- Is a class-body attribute such as `retries = 3` dangerous in the same way?No. An `int` is immutable, so there is no in-place mutation to leak between instances. Any attempt to change it through an instance is an assignment, which creates a shadowing entry in that object's `__dict__` and leaves the class value and every other instance untouched. The trap is specific to mutable objects - lists, dicts, sets and custom objects with mutating methods.
- What does `self.items += [row]` do when `items` is a class-level list?Both things at once. The augmented assignment reads the class list, mutates it in place through the in-place add, and then stores that same object in the instance `__dict__`. So the shared list still grew - other instances see the row - and the instance now holds its own binding to the same list. It is the most confusing spelling of the bug and worth avoiding.
- How would you confirm this diagnosis on a running process?Check ownership rather than value: `vars(instance)` will be empty where you expected state, `"items" in vars(type(instance))` will be `True`, and `a.items is b.items` will be `True` for two independently constructed objects. That identity check is decisive - genuine per-object state can never be the same object across two instances.
It is a single shared notepad nailed to the wall of the classroom, not a fresh page handed to each student. Everyone writing on it sees everyone else's notes, and nobody's page is ever thrown away.
saying these in an interview costs you the question
- Says each instance gets its own copy of the class list
- Thinks the class body re-runs on every instantiation
- Cannot distinguish appending from rebinding
- Blames the garbage collector for the growing memory
- Fixes it by clearing the list at the end of each run
- Claims the same risk applies to an int class attribute