2026-09-13
This TodoList class tracks tasks per user. Each instance is supposed to have its own list of tasks. Simple enough — until Alice and Bob start seeing each other's chores.
class TodoList:
tasks = []
def __init__(self, owner):
self.owner = owner
def add(self, task):
self.tasks.append(task)
def summary(self):
return f"{self.owner}: {len(self.tasks)} tasks"
alice = TodoList("Alice")
alice.add("Buy groceries")
alice.add("Call mom")
bob = TodoList("Bob")
bob.add("Ship release")
print(alice.summary()) # Expected: "Alice: 2 tasks"
print(bob.summary()) # Expected: "Bob: 1 task"
Actual output:
Alice: 3 tasks
Bob: 3 tasks
Bob's release ships onto Alice's todo list. Every new TodoList starts with the tasks of every existing TodoList. Unit tests may pass — the first instance in a fresh process behaves fine — but production drifts into corruption as instances accumulate.
The line tasks = [] sits in the class body, not __init__. In Python, that makes it a class attribute: one list object, owned by TodoList itself, shared by every instance that doesn't shadow it. Coming from Java, C++, or Ruby, the syntax reads like a field declaration with a default initializer. In Python, it isn't. It's an assignment executed once, at class-definition time.
Now the subtle part: why doesn't self.tasks.append(task) at least create a per-instance list? Because append is a method call, not an assignment. Python resolves self.tasks using the usual attribute lookup: instance dict first, then class dict. There's no instance tasks, so it finds the class-level list — and mutates it in place. No new binding is created. Every instance shares the same list forever.
Contrast with self.tasks = [task]. That is an assignment, and it does bind a fresh list on the instance, shadowing the class attribute. So the pattern self.x.append(...) reads from class scope; self.x = ... writes to instance scope. This read/write asymmetry is what makes the bug so hard to spot: the attribute looks like it's per-instance because you can assign to it that way.
This is a cousin of the classic mutable default argument trap — same root cause (a mutable object created once at definition time), different surface (class body vs. function signature). Immutable class attributes (MAX_RETRIES = 3) don't blow up because integers can't be mutated in place; if you rebind, you shadow. Lists, dicts, and sets are the danger zone.
Initialize mutable state inside __init__, so each instance gets its own object:
class TodoList:
def __init__(self, owner):
self.owner = owner
self.tasks = []
def add(self, task):
self.tasks.append(task)
If you want a type hint at the class level for tooling, use dataclasses with field(default_factory=list), which builds a fresh list per instance:
from dataclasses import dataclass, field
@dataclass
class TodoList:
owner: str
tasks: list[str] = field(default_factory=list)
Never write tasks: list[str] = [] in a dataclass body — Python actually raises ValueError at class-definition time for exactly this reason. Regular classes give you no such warning; they'll just quietly cross-contaminate your data.
self.x.append(...) silently mutates the shared version.
