Python's Class-Level Mutable Attribute Trap: The "Instance" List That All Instances Share

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 Bug

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.

The Fix

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.

Key Takeaway: A mutable value written in the class body is a single shared object — instance reads find it, but only instance assignments shadow it, so self.x.append(...) silently mutates the shared version.

All newsletters