Instance vs Class Attributes — and the Mutable Default Trap (Again)
~18 min · instance-attribute, class-attribute, mutable-default
Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
Two kinds of attributes
An attribute defined inside a method (typically __init__) with self.x = ... is an instance attribute — each instance gets its own. An attribute defined at the class level (in the class body, not inside a method) is a class attribute — shared across all instances. Both are accessible via instance.attribute, which makes the distinction subtle.
Lookup order — instance first, then class
When you access obj.x, Python looks in the instance's __dict__ first. If not found, it checks the class. If still not found, it walks up the inheritance chain. This is why obj.x = value creates an instance attribute that shadows the class attribute — it doesn't modify the class.
The mutable class attribute trap
If you put a mutable object as a class attribute (history = [] at class level), every instance shares it. Calling instance.history.append(x) doesn't shadow — it mutates the shared list. This is the same kind of trap as the mutable default argument; the fix is to assign in __init__: self.history = [].
Warning: Class attributes are great for genuine constants and class-wide settings (RETRY_LIMIT = 3), and bad for anything mutable. The rule of thumb: if it's primitive and immutable, class-level is fine. If it's a list, dict, or set, it lives on the instance.
Class-level vs instance-level methods — the difference is __get__
Functions defined in a class body become methods because Python's descriptor protocol turns them into bound methods on access. This is why self appears automatically. Class attributes that aren't functions don't get this treatment — they come back as themselves.
Code
Instance vs class attribute lookup·python
class Robot:
species = "robot" # CLASS attribute — shared
def __init__(self, name):
self.name = name # INSTANCE attribute — per instance
r1 = Robot("R1")
r2 = Robot("R2")
print(r1.species, r2.species) # robot robot — same value
print(r1.name, r2.name) # R1 R2 — different
# Setting on the instance SHADOWS, doesn't modify the class
r1.species = "android"
print(r1.species, r2.species) # android robot
print(Robot.species) # robot — class unchanged
Mutable class attribute — the trap·python
# DON'T DO THIS
class Bad:
history = [] # class attribute — SHARED across instances
def __init__(self, name):
self.name = name
def log(self, event):
self.history.append(event)
a = Bad("a")
b = Bad("b")
a.log("x")
b.log("y")
print(a.history) # ['x', 'y'] <- a saw b's log!
print(b.history) # ['x', 'y']
print(a.history is b.history) # True — same list
# DO THIS — assign on the instance in __init__
class Good:
def __init__(self, name):
self.name = name
self.history = [] # instance attribute — fresh list each time
def log(self, event):
self.history.append(event)
a = Good("a")
b = Good("b")
a.log("x")
b.log("y")
print(a.history) # ['x']
print(b.history) # ['y']
Class-level constants — fine and idiomatic·python
class Connection:
DEFAULT_TIMEOUT = 30 # class attribute — shared CONSTANT
MAX_RETRIES = 3
def __init__(self, host):
self.host = host
def connect(self, timeout=None):
if timeout is None:
timeout = self.DEFAULT_TIMEOUT # access via instance
return f"connecting to {self.host} (timeout={timeout})"
c = Connection("localhost")
print(c.connect()) # uses 30 from class
print(c.connect(timeout=10)) # explicit override
print(Connection.MAX_RETRIES) # access via class directly
Looking at the dicts — instance vs class·python
class Robot:
species = "robot"
def __init__(self, name):
self.name = name
r = Robot("R1")
print(r.__dict__) # {'name': 'R1'} — instance attrs only
print("species" in r.__dict__) # False — not on instance
print("species" in Robot.__dict__) # True — lives on class
# r.species works because Python falls back to the class
print(r.species) # 'robot'
Build a class Quiz with: a class-level constant MAX_QUESTIONS = 10, an instance attribute answers (a list, initialized fresh in __init__), and a method add_answer(text) that appends to answers and raises RuntimeError if len(answers) would exceed MAX_QUESTIONS. Create two Quiz instances and confirm each has its own answers list — adding to one doesn't affect the other. Then deliberately try the trap: re-define Quiz with answers = [] at class level, repeat the test, and observe the bug.
Progress
Progress is local-only — sign in to sync across devices.