Your bank-account report prints Ani's deposit inside Budi's history. The balances are right and nothing raised an error, so nothing pointed at the class body. That is where the bug lives: a mutable class attribute that every account shares.
Here is the output from a script that builds Account("Ani", 100) and Account("Budi", 0), then calls ani.deposit(50) and budi.deposit(20):
ani.balance 150
budi.balance 20
ani.history ['Ani deposit 50', 'Budi deposit 20']
budi.history ['Ani deposit 50', 'Budi deposit 20']
same list: True
Account.history ['Ani deposit 50', 'Budi deposit 20']
ani.__dict__ {'owner': 'Ani', 'balance': 150}
Read the last line. Ani's __dict__ holds owner and balance, and no history. The attribute was never set on the object, so the lookup found the class's list.
Reading self.history looks on the object first, then on the class. self.history.append(x) is a method call on whatever list that lookup returned. It is not an assignment, so nothing gets written to the object. The Python tutorial on classes covers this lookup order.
Key takeaways
- A list assigned in the class body belongs to the class, so every object reads that same list until the object gets one of its own.
self.history.append(x)reads the class attribute and mutates it; it never writes an attribute on the object, which is whyhistoryis absent fromani.__dict__.self.balance = ...looked correct because assignment always writes on the object that holds the name.- Build mutable state in
__init__, and leave a class attribute for values no object should be able to change.
Two files, one command
Start with the broken version. The list sits in the class body, above __init__:
# account.py
class Account:
history = []
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance = self.balance + amount
self.history.append(f"{self.owner} deposit {amount}")
Next, a checker. It builds two accounts, makes one deposit in each, and asks four questions:
# check_account.py
import account
passed = 0
total = 0
def check(name, ok, detail=""):
global passed, total
total += 1
if ok:
passed += 1
print(f"PASS {name}")
else:
print(f"FAIL {name} <- {detail}")
ani = account.Account("Ani", 100)
budi = account.Account("Budi", 0)
ani.deposit(50)
budi.deposit(20)
check("a deposit lands in the account that made it", ani.balance == 150, f"balance is {ani.balance}")
check("the other account's balance does not move", budi.balance == 20, f"balance is {budi.balance}")
check(
"each account has one line of history",
len(ani.history) == 1 and len(budi.history) == 1,
f"ani has {len(ani.history)}, budi has {len(budi.history)}",
)
check(
"the two history lists are different lists",
ani.history is not budi.history,
"both accounts point at the same list",
)
print()
print(f"{passed} of {total} checks passed")
raise SystemExit(0 if passed == total else 1)
Run python3 check_account.py with the starter in place. It exits with code 1:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
FAIL each account has one line of history <- ani has 2, budi has 2
FAIL the two history lists are different lists <- both accounts point at the same list
2 of 4 checks passed
Two checks already pass, and that is the trap. self.balance = ... is an assignment, and assignment always writes on the object, so each account gets its own balance. self.history.append(...) never assigns anything. Balances are the first thing you would test, and they look fine.
Now move the list into __init__, so each object creates its own:
# account.py
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = []
def deposit(self, amount):
self.balance = self.balance + amount
self.history.append(f"{self.owner} deposit {amount}")
Run the same command again. This time it exits with code 0:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
PASS each account has one line of history
PASS the two history lists are different lists
4 of 4 checks passed
I ran all of this on Python 3.14.9 on Linux. I did not try other Python versions, so this is the evidence from one machine.
What this does not do
A class attribute is still the right home for a value nobody mutates. The checker cannot tell a shared constant from a shared list, and it should not. Sharing was never the problem. Mutating something shared was.
An empty history is also not proof that you fixed it. Every account starts with an empty list in both versions. The bug only shows once a second object exists, which is exactly what the checker builds.
Break it on purpose
Put history = [] back in the class body. Then add one line to __init__:
self.history = Account.history
It looks like each account now gets its own list. Run the checker and two checks fail again:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
FAIL each account has one line of history <- ani has 2, budi has 2
FAIL the two history lists are different lists <- both accounts point at the same list
2 of 4 checks passed
The new line copies the same list object into both objects, so the objects still share it.
Before you put a list, dict, or set in a class body, ask one question: should a second object see this change? If yes, share it on purpose. If no, build it in __init__.