Intuition What this page is
The parent note gave you one rule that explains everything:
READ obj.x → search instance first, then class , then base classes.
WRITE obj.x = ... → always lands on the instance, never the class.
This page throws every kind of situation at that rule until it stops surprising you. We first draw a matrix of all the cases that can occur, then work an example for each cell.
Think of every attribute-access as choosing three switches :
Where do we touch it? — on the instance (obj.) or on the class (ClassName.)
Read or write? — reading (print(obj.x), obj.x.append(...)) or writing (obj.x = ...)
What kind of value? — immutable (int, str, tuple) or mutable (list, dict, set)
Every bug and every "wait, why?" moment is just one box in this table:
#
Touch
Action
Value kind
What happens
Cell name
C1
instance
read
any
falls through to class if not shadowed
fall-through read
C2
instance
write
immutable
creates instance copy → shadows class
shadow write
C3
class
write
any
changes the one shared copy for all non-shadowers
class rewrite
C4
instance
read+mutate
mutable
mutates the shared class object (no copy!)
mutable trap
C5
instance
write then mutate
mutable
own copy → safe, isolated
the fix
C6
instance
delete (del)
any
removes shadow → class value reappears
un-shadow
C7
class
read via __dict__
any
proves where the value physically lives
namespace proof
C8
zero/degenerate
—
—
empty class, no instance attrs, hasattr on nothing
degenerate
C9
inheritance
read
any
child instance falls through to parent class
MRO fall-through
C10
counter idiom
write on class
immutable
a class attribute used as a global tally
word problem
Below: one worked example per cell. Guess the output before reading the steps.
Worked example Two instances, no shadowing
class Dog :
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" ); b = Dog( "Fido" )
print (a.species, b.species)
Forecast: what prints? Do they share or differ?
Steps
Read a.species. Why this step? To trigger the lookup rule. Python checks a.__dict__ first → only name is there, no species.
Fall through to type(a).__dict__ = Dog.__dict__. Why this step? Rule step 2: the class is searched next. It finds "Canis familiaris".
Same for b. Why this step? b.__dict__ also lacks species, so it too falls through to the same class copy.
Output: Canis familiaris Canis familiaris
Verify: a.species is b.species is True — literally the same object in memory, because both reads resolved to the one class copy.
Worked example Writing an instance attribute hides the class one
class Dog :
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" ); b = Dog( "Fido" )
a.species = "Wolf"
print (a.species, b.species, Dog.species)
Forecast: which of the three changed?
Steps
Execute a.species = "Wolf". Why this step? It is a write . By the write rule it cannot touch the class — it creates key species inside a.__dict__.
Read a.species. Why this step? Rule step 1 now succeeds on the instance : it finds "Wolf" and stops. The class copy is shadowed (hidden), not erased.
Read b.species and Dog.species. Why this step? b.__dict__ still has no species, so it falls through to the untouched class → "Canis familiaris". The class copy never moved.
Output: Wolf Canis familiaris Canis familiaris
Verify: 'species' in a.__dict__ is True, but 'species' in b.__dict__ is False.
Worked example Changing the shared copy for everyone
class Dog :
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" ); b = Dog( "Fido" )
a.species = "Wolf" # a now shadows
Dog.species = "Doggo" # rewrite the ONE shared copy
print (a.species, b.species)
Forecast: does a see "Doggo"? Does b?
Steps
a.species = "Wolf". Why this step? Gives a its own shadow (as in C2).
Dog.species = "Doggo". Why this step? This is a write on the class name , so it edits the single class copy directly.
Read a.species. Why this step? Rule step 1 finds a's own "Wolf" first — a never reaches the class, so it does not see "Doggo".
Read b.species. Why this step? b has no shadow → falls through (step 2) to the now-updated class → "Doggo".
Output: Wolf Doggo
Verify: shadowers ignore class rewrites; non-shadowers see them live.
Worked example One list, everybody appends to it
class Dog :
tricks = [] # ONE shared list
def __init__ (self, name): self .name = name
def add_trick (self, t): self .tricks.append(t)
a = Dog( "Rex" ); b = Dog( "Fido" )
a.add_trick( "roll" )
print (a.tricks, b.tricks)
Forecast: how many tricks does poor Fido (b) know?
Steps
Inside add_trick, evaluate self.tricks. Why this step? This is a read , not a write. a.__dict__ has no tricks, so it falls through to the class list.
Call .append("roll") on it. Why this step? We are mutating the object we read . No = occurred, so no instance copy was ever created .
Read b.tricks. Why this step? b also falls through to the same class list — which now contains "roll".
Output: ['roll'] ['roll'] 😱
Verify: a.tricks is b.tricks is Dog.tricks is True — all three names point at one list object.
Worked example Give each dog its own fresh list
class Dog :
def __init__ (self, name):
self .name = name
self .tricks = [] # assignment → own copy
def add_trick (self, t): self .tricks.append(t)
a = Dog( "Rex" ); b = Dog( "Fido" )
a.add_trick( "roll" )
print (a.tricks, b.tricks)
Forecast: now how many tricks does Fido know?
Steps
In __init__, run self.tricks = []. Why this step? This is a write → a fresh empty list goes into this instance's __dict__. Each dog runs its own __init__, so each gets a different list.
a.add_trick("roll") reads a.tricks (found on the instance) and appends. Why this step? The read now stops at the instance's private list — the class never enters the picture.
Read b.tricks. Why this step? b has its own separate empty list, untouched by a.
Output: ['roll'] []
Verify: a.tricks is b.tricks is now False — two distinct list objects.
Worked example Deleting the instance copy reveals the class value again
class Dog :
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" )
a.species = "Wolf" # shadow
print (a.species) # ?
del a.species # remove the shadow
print (a.species) # ?
Forecast: what does the second print show — an error, or the class value?
Steps
a.species = "Wolf" — creates the shadow (C2).
First read → instance copy → "Wolf". Why this step? Confirms the shadow is in place.
del a.species. Why this step? del removes the key only from the instance __dict__. The class copy is untouched.
Second read → instance now empty of species → fall through (rule step 2) to class. Why this step? With the shadow gone, lookup continues past the instance to the surviving class value.
Output: Wolf then Canis familiaris
Verify: del a.species a second time raises AttributeError — because del only ever targets the instance, and there is nothing left there to delete.
where each value lives
class Dog :
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" )
print ( 'species' in a. __dict__ ) # ?
print ( 'species' in Dog. __dict__ ) # ?
print ( 'name' in a. __dict__ ) # ?
Forecast: which container holds which key?
Steps
Check a.__dict__ for species. Why this step? species was never assigned via self., so it is not in the instance namespace → False.
Check Dog.__dict__ for species. Why this step? It was defined in the class body → it lives in the class namespace → True.
Check a.__dict__ for name. Why this step? name came from self.name = name → it is on the instance → True.
Output: False, True, True
Verify: namespaces are literal dictionaries; in on them is the ground truth for "who owns this attribute."
Worked example Empty class, missing attributes,
getattr defaults
class Empty :
pass
e = Empty()
print ( hasattr (e, 'anything' )) # ?
print ( getattr (e, 'x' , 'default' )) # ?
e.x = 10
print (e. __dict__ ) # ?
Forecast: does a class with no attributes crash when you ask for one?
Steps
hasattr(e, 'anything'). Why this step? Lookup searches instance (empty), class (empty body → only auto stuff), bases (object) — none has anything → False. No crash ; hasattr catches the AttributeError.
getattr(e, 'x', 'default'). Why this step? Same failed lookup, but the third argument is a fallback → returns 'default' instead of raising.
e.x = 10 then inspect __dict__. Why this step? A write always creates an instance attribute, even on a class that declared nothing → {'x': 10}.
Output: False, default, {'x': 10}
Verify: the "zero attributes" case degrades gracefully — the lookup rule just runs off the end and raises/falls back, never corrupts anything.
Worked example Child instance falls through to the parent class
class Animal :
kingdom = "Animalia" # class attribute on the PARENT
class Dog ( Animal ):
species = "Canis familiaris"
def __init__ (self, name): self .name = name
a = Dog( "Rex" )
print (a.kingdom) # ?
print (a.species) # ?
a.kingdom = "Robot" # write
print (a.kingdom, Animal.kingdom) # ?
Forecast: where does kingdom come from, and does writing it touch Animal?
Steps
Read a.kingdom. Why this step? Rule step 3: instance empty → Dog has no kingdom → continue up the MRO to Animal → "Animalia".
Read a.species. Why this step? Found earlier — on Dog (step 2 of the rule) — no need to reach Animal.
a.kingdom = "Robot". Why this step? A write → instance copy on a, shadowing the parent's value. Neither Dog nor Animal is modified.
Read a.kingdom and Animal.kingdom. Why this step? a sees its own shadow "Robot"; Animal still holds the pristine "Animalia".
Output: Animalia, Canis familiaris, then Robot Animalia
Verify: see Inheritance and MRO — the read search simply extends through parent classes; the write rule is unchanged (always instance).
Worked example Count how many
BankAccount objects were ever created
A bank wants a live tally of accounts opened. Use a class attribute as a shared counter that every constructor bumps.
class BankAccount :
count = 0 # shared tally
def __init__ (self, owner):
self .owner = owner # per-object
BankAccount.count += 1 # bump on the CLASS
x = BankAccount( "Asha" )
y = BankAccount( "Ben" )
z = BankAccount( "Cara" )
print (BankAccount.count, x.count, y.count)
Forecast: what is count after three accounts? And why BankAccount.count +=, not self.count +=?
Steps
Start with count = 0 in the class body → one shared int.
Each __init__ runs BankAccount.count += 1. Why this step? BankAccount.count += 1 reads then writes on the class , so the single shared copy grows. If we wrote self.count += 1 instead, the read would find 0 on the class, add 1, then write onto the instance (per the write rule) — creating a private count = 1 on each object and leaving the class stuck at 0. That is the classic counter bug.
After three constructions the shared copy is 0 + 1 + 1 + 1 = 3.
Read x.count, y.count. Why this step? Neither instance shadows count, so both fall through to the class → both read 3.
Output: 3 3 3
Verify: because we mutated on the class name (not self), the tally is genuinely global; every instance reads the same live count. Contrast mutable vs immutable : int is immutable, so += 1 rebinds a new int — which is exactly why you must aim that rebind at the class , not the instance.
Recall One-line summary of the whole matrix
Every cell reduces to two facts.
What are the two facts that generate every case? ::: READ searches instance → class → bases; WRITE (including +=, del) always targets the instance unless you name the class explicitly.
Recall Self-test across the matrix
(C4 vs C5) Why does self.tricks.append(x) share but self.tricks = []; ...append(x) isolate?
(C6) After del a.species, why does a.species still return a value instead of crashing?
(C10) Why must the counter use ClassName.count += 1 and not self.count += 1?
In C1, why is a.species is b.species True? Both reads fall through to the one shared class copy, so both names point at the same object.
In C2, after a.species = "Wolf", what is in b.__dict__? Nothing named species — b still falls through to the untouched class value.
In C4, what is a.tricks is Dog.tricks? True — the append only read+mutated the single shared class list; no instance copy was made.
In C6, what does a second del a.species raise? AttributeError, because del only removes from the instance and the shadow is already gone.
In C8, what does getattr(e, 'x', 'default') return when x is undefined everywhere? The fallback string 'default' — the third argument suppresses the AttributeError.
In C9, after a.kingdom = "Robot", what is Animal.kingdom? Still "Animalia" — the write created a shadow on the instance, never touching the parent class.
In C10, why does self.count += 1 fail as a counter? It reads the class value then WRITES onto the instance, giving each object a private count = 1 while the class stays at 0.
In C10, what is BankAccount.count after creating 3 accounts with BankAccount.count += 1? 3 — each constructor bumped the single shared class copy.
OOP Fundamentals
Classes and objects
The self parameter
__init__ constructor
Inheritance and MRO
Namespaces and scope
Mutable vs immutable objects