Instance and Class Attributes
Python objects carry two kinds of attributes: instance attributes, attached to one object via self.x and unique to it, and class attributes, defined in the class body and shared by every instance. The distinction is the source of both Python's conciseness (class-level constants, counters, registries) and its classic bugs (a mutable class attribute silently shared across all instances). This chapter makes the lookup rule explicit - Python checks the instance first, then the class - and gives you the patterns for using each kind correctly.
The Lookup Rule
class Dog:
species = "Canis familiaris" # class attribute: one for all dogs
registry = [] # class attribute: a shared collection
def __init__(self, name):
self.name = name # instance attribute: per dog
Dog.registry.append(self)
rex = Dog("Rex")
fido = Dog("Fido")
print(rex.name) # 'Rex' - found on the instance
print(rex.species) # found on the CLASS (rex has no own species)
print(rex is fido) # False
print(rex.species is fido.species) # True - same object on the class
print(len(Dog.registry)) # 2 - the shared list saw both dogs
When you write rex.species, Python looks on the instance first, finds nothing, then looks on the class and finds it there. Class attributes are the fallback for anything the instance does not define. That is why a class attribute can be read through any instance (convenient) but should be written only through the class name (explicit): rex.species = "X" does not change the class - it shadows it by creating an instance attribute of the same name, which is the most confusing line in Python OOP for newcomers.
Instance Attributes: The Normal Case
class Player:
def __init__(self, name):
self.name = name
self.score = 0
self.moves = [] # mutable, but it is an instance attribute
def win(self):
self.score += 100
p1 = Player("Ada")
p2 = Player("Grace")
p1.win()
p1.moves.append("castle")
print(p1.score, p2.score) # 100 0 - independent
print(p1.moves) # ['castle'] - p2 has its own empty list
Per-object state lives on the instance, set in __init__ (or later, though __init__ is the norm). Mutable instance attributes are safe - each object gets its own list from its own __init__ call. The danger is not "mutable," it is "shared," and sharing happens when the attribute is created on the class instead of the instance.
Class Attributes: Constants, Counters, Registries
class Order:
_counter = 0 # shared counter
TAX_RATE = 0.1 # shared constant
def __init__(self, total):
Order._counter += 1 # update THROUGH the class name
self.id = Order._counter
self.total = total
self.tax = round(total * Order.TAX_RATE, 2)
a = Order(100)
b = Order(200)
print(a.id, b.id) # 1 2 - the counter advanced for both
print(a.TAX_RATE) # 0.1 - readable through any instance
print(Order.__dict__["_counter"]) # 2 - the class really holds it
The three legitimate class-attribute jobs: constants (configuration every instance reads but never writes), counters and registries (state that is inherently about the class, not any one object), and defaults (a template an instance may shadow deliberately). The discipline: read through whatever is handy, but write through the class name - Order._counter += 1, never self._counter += 1 - because self._counter += 1 reads the class value, adds, and then stores the result on the instance, quietly breaking the sharing for that object.
The Mutable Class Attribute Trap
class Broken:
tags = [] # ONE list for every Broken object
def __init__(self):
self.tags.append("default")
x = Broken()
y = Broken()
print(x.tags) # ['default', 'default']
print(y.tags) # same list - x's append is visible on y
class Fixed:
def __init__(self):
self.tags = ["default"] # a NEW list per instance
x = Fixed()
y = Fixed()
print(x.tags) # ['default']
print(y.tags) # ['default'] - independent
The bug is invisible until two instances interact: one mutation appears on every object because they all point at the same list on the class. The fix is to create the mutable in __init__ so each instance owns its own. The same trap hides in default arguments (def f(items=[])) - the fix pattern is identical (use None, build inside). When you do want a shared mutable (a cache, a registry), it is a deliberate class attribute with a documented contract, not an accident.
Introspecting Attributes
class Demo:
shared = 1
def __init__(self):
self.own = 2
d = Demo()
print(vars(d)) # {'own': 2} - instance __dict__ only
print(Demo.__dict__) # includes 'shared', methods, __dict__ etc.
print(hasattr(d, "shared")) # True
print(getattr(d, "missing", "N/A")) # 'N/A' - safe access
d.new = 3 # instance attributes can appear at any time
print(d.__dict__) # {'own': 2, 'new': 3}
vars(obj) (equivalently obj.__dict__) shows the instance's own storage; the class's __dict__ shows the class's. hasattr and getattr(obj, name, default) are the safe-access pair for code that must handle optional attributes - the pattern behind configuration objects and plugin interfaces. Knowing that instance attributes can be added after construction (they are just keys in __dict__) is also why __slots__ exists: it replaces the per-instance dictionary with fixed slots, trading flexibility for memory and speed - a tool for the millions-of-objects case.
Summary
Two storages, one rule: instance attributes (self.x) are per-object; class attributes are shared, and Python looks on the instance first, then the class. Use class attributes for constants, counters, and registries - and write them through the class name so you do not accidentally shadow them. Create every mutable you want to be per-instance inside __init__, because a mutable defined on the class is one shared object for all instances - the classic Python OOP bug. Introspect with vars, __dict__, hasattr, getattr when you need safe or dynamic access. With the lookup rule internalized, the next chapter covers the other side of the object: methods and the self parameter.