Classes and Objects - Python's Object Model at Engineering Depth
Reading time: ~30 minutes | Level: Intermediate → Engineering
Before reading further, predict every output of this program:
class Counter:
count = 0
def increment(self):
self.count += 1
a = Counter()
b = Counter()
a.increment()
print(a.count) # ?
print(b.count) # ?
print(Counter.count) # ?
Most developers predict 1, 1, 1. The actual output is 1, 0, 0.
Then consider this variation:
class Registry:
items = []
def add(self, item):
self.items.append(item)
x = Registry()
y = Registry()
x.add("alpha")
print(y.items) # ?
Here the output is ["alpha"]. y sees x's addition.
Same pattern. Opposite behavior. Understanding why - precisely, not approximately - is what separates Python engineers from Python users.
What You Will Learn
- What a class actually is at the CPython level
- The difference between class attributes and instance attributes
- How Python's attribute resolution chain works (
__dict__, class, bases) - Why
self.count += 1creates an instance attribute butself.items.append()does not - How
typeis the metaclass of every class - What happens line by line when Python executes a class body
- How to inspect the object model at runtime with
__dict__,type(),dir() - The shared mutable attribute trap and how to avoid it
Prerequisites
- Python Foundation: Variables, functions, basic data structures
- Understanding that functions are objects in Python (Foundation Module 12)
- Comfortable reading code with
self
Part 1 - What Is a Class?
A Class Is an Object
In Python, a class is not a template or a blueprint in the static sense. A class is a live object - an instance of type.
class Dog:
species = "Canis familiaris"
print(type(Dog)) # <class 'type'>
print(type(42)) # <class 'int'>
print(type("hello")) # <class 'str'>
Dog is an object. int is an object. str is an object. They are all instances of type. type is Python's built-in metaclass - the class of all classes.
This is not a theoretical detail. It means you can do this:
# Inspect the class at runtime
print(Dog.__name__) # Dog
print(Dog.__bases__) # (<class 'object'>,)
print(Dog.__dict__) # mappingproxy({'species': 'Canis familiaris', ...})
Every class carries its namespace in __dict__. Every attribute defined at the class level lives there.
The Class Body Executes Immediately
When Python encounters a class statement, it does not just record the definition for later. It executes the class body immediately, as a block of code, in a fresh namespace.
print("before class")
class Tracer:
print("inside class body") # executes NOW
x = 10 + 5
print(f"x is {x}") # executes NOW
print("after class")
Output:
before class
inside class body
x is 15
after class
This has implications. You can put arbitrary Python code in a class body - conditionals, loops, function calls. The results become class attributes.
import sys
class Config:
debug = sys.argv[0].endswith("test")
max_workers = 4 if sys.platform == "darwin" else 8
Both debug and max_workers are class attributes computed at class definition time.
type() Called Directly
Everything you do with class syntax can be done explicitly with type():
# These two are equivalent
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
Point = type("Point", (object,), {
"__init__": lambda self, x, y: setattr(self, "x", x) or setattr(self, "y", y)
})
type(name, bases, namespace) is the direct API. The class keyword is syntactic sugar on top of it.
Frameworks use this to create classes dynamically. Django's ORM, for example, creates model classes from database schema at runtime using the type() API.
Part 2 - Instances and the Namespace Chain
Creating an Instance
When you call a class like a function, Python creates a new instance:
class Dog:
species = "Canis familiaris"
def __init__(self, name, breed):
self.name = name
self.breed = breed
rex = Dog("Rex", "Labrador")
rex is an instance of Dog. It has its own __dict__:
print(rex.__dict__) # {'name': 'Rex', 'breed': 'Labrador'}
print(Dog.__dict__) # mappingproxy({'species': 'Canis familiaris', '__init__': ..., ...})
name and breed live on the instance. species lives on the class.
The Dog class above has a clear split between what is shared (class-level) and what is per-instance. Here is a diagram showing exactly where each attribute lives:
The Attribute Resolution Chain
When you access rex.species, Python does not find species in rex.__dict__. It follows the lookup chain:
1. rex.__dict__ → not found
2. type(rex).__dict__ → Dog.__dict__ → found: "Canis familiaris"
3. Dog's base classes → (would continue up the MRO if not found)
This is the attribute resolution order for instances. For data descriptors (like property), the order is slightly different - but for plain attributes, this chain is the rule.
print(rex.species) # "Canis familiaris" - from Dog.__dict__
print(rex.name) # "Rex" - from rex.__dict__
rex.species = "Override" # creates NEW entry in rex.__dict__
print(rex.species) # "Override" - now from rex.__dict__
print(Dog.species) # "Canis familiaris" - class unchanged
Instance attributes shadow class attributes when they share a name. The class attribute is not modified.
Visualising the Namespaces
Dog (class object)
├── __dict__:
│ ├── species = "Canis familiaris"
│ ├── __init__ = <function>
│ └── ...
rex (instance object)
├── __dict__:
│ ├── name = "Rex"
│ └── breed = "Labrador"
└── __class__ → Dog
When you read rex.species:
- Python looks in
rex.__dict__→ not there - Python looks in
rex.__class__.__dict__(which isDog.__dict__) → found
When you write rex.species = "Override":
- Python writes to
rex.__dict__directly Dog.__dict__is not touched
Part 3 - The Shared Mutable Attribute Trap
Now we can explain the opening puzzles precisely.
Why self.count += 1 Does NOT Mutate the Class
class Counter:
count = 0
def increment(self):
self.count += 1 # this is self.count = self.count + 1
self.count += 1 is syntactic sugar for self.count = self.count + 1.
- Read:
self.count→ not in instance dict → found inCounter.__dict__→0 - Write:
self.count = 0 + 1→ creates new entry in instance__dict__
After a.increment():
a.__dict__={'count': 1}Counter.__dict__['count']=0(unchanged)b.__dict__={}(empty -bnever incremented)
a = Counter()
b = Counter()
a.increment()
print(a.count) # 1 - from a.__dict__
print(b.count) # 0 - from Counter.__dict__ (b.__dict__ is empty)
print(Counter.count) # 0 - unchanged
The augmented assignment created a new instance attribute on a. It did not modify the class.
Why self.items.append() DOES Mutate the Class
class Registry:
items = []
def add(self, item):
self.items.append(item) # NOT self.items = ...
self.items.append(item) does not assign to self.items. It:
- Reads
self.items→ not in instance dict → found inRegistry.__dict__→ the class-level list - Calls
.append(item)on that list → mutates the class-level list in place
No assignment to self.items ever happens. The instance dict is never written. Both x and y share the same list object from Registry.__dict__.
x = Registry()
y = Registry()
x.add("alpha")
print(x.items) # ["alpha"]
print(y.items) # ["alpha"] - same object
print(x.items is y.items) # True
print(x.items is Registry.items) # True - all three point to the same list
:::danger Shared Mutable Class Attribute Trap A mutable object (list, dict, set) defined at class level is shared across every instance. Any mutation through one instance is visible through all others.
class Registry:
items = [] # DANGER: every instance shares this exact list object
x = Registry()
y = Registry()
x.items.append("alpha")
print(y.items) # ["alpha"] - y is contaminated!
This is one of the most common and hardest-to-debug bugs in Python OOP. The class attribute is not copied per instance - it is one object referenced by all. :::
The Fix: Use __init__ for Instance State
Mutable state should always live on instances, initialised in __init__:
class Registry:
def __init__(self):
self.items = [] # each instance gets its OWN list
def add(self, item):
self.items.append(item)
x = Registry()
y = Registry()
x.add("alpha")
print(x.items) # ["alpha"]
print(y.items) # []
print(x.items is y.items) # False - different objects
:::tip Always Initialise Mutable State in __init__
Move every mutable attribute - lists, dicts, sets - out of the class body and into __init__. Class-level attributes are fine for immutable constants only.
class Dog:
species = "Canis familiaris" # OK: immutable string, genuinely shared
def __init__(self, name):
self.name = name
self.tricks = [] # OK: mutable, created fresh per instance in __init__
:::
Rule: Use class attributes only for data that is genuinely shared across all instances - constants, configuration defaults, registries. Never for mutable state.
class Dog:
# Good: shared constant, never mutated
species = "Canis familiaris"
sound = "Woof"
def __init__(self, name):
# Good: per-instance mutable state
self.name = name
self.tricks = [] # each dog gets its own list
Part 4 - Methods Are Functions
How self Works
Methods are functions defined in the class body. When accessed via an instance, Python wraps them in a bound method that automatically passes the instance as the first argument.
class Greeter:
def hello(self):
print(f"Hello from {self}")
g = Greeter()
# These are equivalent:
g.hello() # Python passes g as self automatically
Greeter.hello(g) # you pass g explicitly
self is not a keyword. It is a convention. The first argument of any instance method receives the instance. You could name it this or me - but do not.
print(type(Greeter.hello)) # <class 'function'>
print(type(g.hello)) # <class 'method'>
Greeter.hello is a plain function. g.hello is a bound method - a function bound to the instance g.
:::note self Is Just a Convention
Python does not enforce the name self. The first parameter of any instance method simply receives the instance object. Naming it self is a community standard (PEP 8), not a language rule.
class Broken:
def greet(me): # works - 'me' receives the instance
print(me)
def also_works(this): # also works
print(this)
Your code will run, but your colleagues (and future you) will not thank you for it. Always use self.
:::
Class Methods and Static Methods
class Temperature:
unit = "Celsius"
def __init__(self, value):
self.value = value
@classmethod
def from_fahrenheit(cls, f):
"""Factory method - receives the class, not an instance."""
return cls((f - 32) * 5 / 9)
@staticmethod
def convert_f_to_c(f):
"""Utility - no class or instance needed."""
return (f - 32) * 5 / 9
def display(self):
return f"{self.value:.1f}°{self.unit}"
| Decorator | First arg | Use case |
|---|---|---|
| (none) | self - instance | Instance operations |
@classmethod | cls - the class | Factory methods, alternate constructors |
@staticmethod | (none) | Utility functions logically grouped with class |
t1 = Temperature(100)
t2 = Temperature.from_fahrenheit(212) # classmethod
print(t1.display()) # 100.0°Celsius
print(t2.display()) # 100.0°Celsius
print(Temperature.convert_f_to_c(32)) # 0.0
Part 5 - Inspecting the Object Model
Python exposes the entire object model at runtime. Use this.
class Vehicle:
wheels = 4
def __init__(self, make, model):
self.make = make
self.model = model
def describe(self):
return f"{self.make} {self.model}"
car = Vehicle("Toyota", "Camry")
# Instance inspection
print(car.__dict__) # {'make': 'Toyota', 'model': 'Camry'}
print(car.__class__) # <class '__main__.Vehicle'>
print(car.__class__.__name__) # Vehicle
# Class inspection
print(Vehicle.__dict__) # mappingproxy with all class attributes + methods
print(Vehicle.__bases__) # (<class 'object'>,)
print(Vehicle.__mro__) # (<class 'Vehicle'>, <class 'object'>)
# Runtime type checking
print(isinstance(car, Vehicle)) # True
print(isinstance(car, object)) # True - everything is an object
print(type(car) is Vehicle) # True
print(type(car) is object) # False - type() is not isinstance()
# Attribute introspection
print(dir(car)) # all attributes + methods including inherited
print(hasattr(car, "make")) # True
print(hasattr(car, "doors")) # False
print(getattr(car, "make")) # "Toyota"
print(getattr(car, "doors", "unknown")) # "unknown" - safe default
Part 6 - __slots__ for Memory-Constrained Classes
By default, every instance has a __dict__ - a hash map. This is flexible but consumes memory. For classes where you create millions of instances, __slots__ eliminates per-instance __dict__:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p.x) # 3
print(p.y) # 4
# p.z = 5 # AttributeError - slots are fixed
# print(p.__dict__) # AttributeError - no __dict__
Memory comparison:
import sys
class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
pd = PointDict(1, 2)
ps = PointSlots(1, 2)
print(sys.getsizeof(pd) + sys.getsizeof(pd.__dict__)) # ~232 bytes
print(sys.getsizeof(ps)) # ~56 bytes
Use __slots__ when:
- You create very large numbers of instances (>100k)
- Memory is a constraint
- The attribute set is fixed and known at design time
Do not use __slots__ by default - it removes flexibility and makes inheritance complex.
Common Mistakes
Mistake 1 - Mutable Default Class Attribute
# Wrong
class Node:
children = [] # shared across ALL instances
# Right
class Node:
def __init__(self):
self.children = [] # each instance owns its list
Mistake 2 - Checking Type with == Instead of isinstance
# Wrong - breaks polymorphism
if type(obj) == Dog:
...
# Right - works with subclasses
if isinstance(obj, Dog):
...
Mistake 3 - Assuming self Is Magic
class Broken:
def greet(me): # works - 'me' receives the instance
print(me)
def also_works(this): # also works
print(this)
self is a convention enforced by the Python community (PEP 8). The interpreter does not care what you name it. Your colleagues will.
Engineering Checklist
Before moving to the next lesson, verify you can answer these without looking:
- What is
type(MyClass)? - Where do class attributes live?
- Where do instance attributes live?
- What happens when you do
self.attr += 1andattronly exists on the class? - What happens when you call
.append()on a class-level list viaself? - What is the difference between
type(x) is Dogandisinstance(x, Dog)? - When does
@classmethodmake sense over a regular method? - What does
__slots__do and when should you use it?
If any of those are uncertain - reread the relevant section. These are the mental models that all subsequent lessons build on.
Quick Reference
# Create a class
class MyClass:
class_attr = "shared" # class attribute
def __init__(self, val):
self.instance_attr = val # instance attribute
def method(self): # instance method
return self.instance_attr
@classmethod
def factory(cls, data): # class method
return cls(data)
@staticmethod
def util(x): # static method
return x * 2
# Inspect
obj = MyClass("hello")
print(obj.__dict__) # instance namespace
print(MyClass.__dict__) # class namespace
print(obj.__class__) # MyClass
print(MyClass.__bases__) # (object,)
print(isinstance(obj, MyClass)) # True
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
class Counter:
count = 0
def increment(self):
self.count += 1
a = Counter()
b = Counter()
a.increment()
a.increment()
print(a.count)
print(b.count)
print(Counter.count)
Show Answer
Output:
2
0
0
self.count += 1 expands to self.count = self.count + 1. The read resolves to Counter.count (0), the write creates an entry in a.__dict__. After two calls, a.__dict__['count'] is 2. b.__dict__ is still empty, so b.count falls through to Counter.count which is 0. Counter.count itself was never touched.
Question 2: What does this print?
class Registry:
items = []
def add(self, item):
self.items.append(item)
x = Registry()
y = Registry()
x.add("alpha")
y.add("beta")
print(len(Registry.items))
print(x.items is y.items)
Show Answer
Output:
2
True
Both x.add and y.add read self.items from Registry.__dict__ and mutate that single shared list. No instance attribute is ever created. After two add calls, the class-level list contains both "alpha" and "beta". All three references (x.items, y.items, Registry.items) point to the same object.
Question 3: What does this print?
class Dog:
species = "Canis familiaris"
rex = Dog()
rex.species = "Canis lupus"
print(rex.species)
print(Dog.species)
Show Answer
Output:
Canis lupus
Canis familiaris
rex.species = "Canis lupus" writes a new entry into rex.__dict__. It does not touch Dog.__dict__. When Python resolves rex.species, it finds the entry in rex.__dict__ first and returns it. Dog.species is unchanged.
Question 4: What does this print?
class Greeter:
def hello(self):
return "hello"
g = Greeter()
print(type(Greeter.hello))
print(type(g.hello))
print(g.hello is Greeter.hello)
Show Answer
Output:
<class 'function'>
<class 'method'>
False
Greeter.hello is a plain function object stored in Greeter.__dict__. g.hello is a bound method - a new wrapper object created each time you access a method via an instance. Each access creates a fresh bound method object, so g.hello is Greeter.hello is False.
Question 5: What does this print?
class Animal:
legs = 4
class Cat(Animal):
pass
c = Cat()
print(c.legs)
print(Cat.__dict__.get("legs", "not found"))
print(Animal.__dict__.get("legs", "not found"))
Show Answer
Output:
4
not found
4
c.legs is not in c.__dict__. Python walks the MRO: Cat.__dict__ does not have legs either. Python continues to Animal.__dict__ where legs = 4 is found. Cat.__dict__.get("legs") returns "not found" because Cat never defined legs itself.
Level 2 - Debug Challenge
Find and fix all bugs in this code:
class BankAccount:
balance = 0
transactions = []
def deposit(self, amount):
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
self.balance -= amount
self.transactions.append(("withdraw", amount))
alice = BankAccount()
bob = BankAccount()
alice.deposit(100)
bob.deposit(200)
print(alice.balance) # expected: 100
print(bob.balance) # expected: 200
print(alice.transactions) # expected: [('deposit', 100)]
print(bob.transactions) # expected: [('deposit', 200)]
Show Solution
The bugs:
-
balance = 0- This works correctly by accident for integers.self.balance += amountexpands toself.balance = self.balance + amount, which reads the class attribute and writes to the instance. Soalice.balanceandbob.balanceare independent. But it's still bad practice to rely on this - class-level numeric attributes used as "defaults" are fragile and confusing. -
transactions = []- This is the real bug.self.transactions.append(...)never assigns toself.transactions, so it always mutates the class-level list. Both alice and bob share the same list.
Fixed version:
class BankAccount:
def __init__(self):
self.balance = 0
self.transactions = [] # each account gets its own list
def deposit(self, amount):
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
self.balance -= amount
self.transactions.append(("withdraw", amount))
alice = BankAccount()
bob = BankAccount()
alice.deposit(100)
bob.deposit(200)
print(alice.balance) # 100
print(bob.balance) # 200
print(alice.transactions) # [('deposit', 100)]
print(bob.transactions) # [('deposit', 200)]
Level 3 - Design Challenge
Design a Plugin registry system that:
- Tracks all registered plugin classes automatically (without manual registration calls)
- Allows each plugin instance to have its own independent configuration dict
- Provides a class method
all_plugins()that returns the list of registered plugin classes - Makes it impossible for a bug in one plugin's config to affect another plugin's config
Write the implementation and demonstrate all four requirements are met.
Show Reference Solution
class PluginBase:
_registry = [] # class attribute: intentionally shared - it IS the registry
def __init_subclass__(cls, **kwargs):
"""Called automatically when a class inherits from PluginBase."""
super().__init_subclass__(**kwargs)
PluginBase._registry.append(cls)
def __init__(self, **config):
# Each instance gets its own config dict - requirement 2
self.config = dict(config) # copy prevents aliasing bugs
@classmethod
def all_plugins(cls):
"""Returns all registered plugin subclasses - requirement 3."""
return list(PluginBase._registry)
def describe(self):
return f"{type(self).__name__}(config={self.config})"
# Registration is automatic - requirement 1
class ImagePlugin(PluginBase):
pass
class AudioPlugin(PluginBase):
pass
class VideoPlugin(PluginBase):
pass
# Demonstrate all requirements
print(PluginBase.all_plugins())
# [<class 'ImagePlugin'>, <class 'AudioPlugin'>, <class 'VideoPlugin'>]
# Each instance has its own config - requirement 2
p1 = ImagePlugin(format="png", quality=90)
p2 = ImagePlugin(format="jpeg", quality=70)
print(p1.describe()) # ImagePlugin(config={'format': 'png', 'quality': 90})
print(p2.describe()) # ImagePlugin(config={'format': 'jpeg', 'quality': 70})
# Mutating one config does not affect the other - requirement 4
p1.config["quality"] = 100
print(p2.config["quality"]) # 70 - unaffected
# Works with class method from any subclass
print(ImagePlugin.all_plugins())
# Same list - _registry lives on PluginBase
Key design decisions:
_registryis intentionally a class attribute onPluginBase- it is shared because we want a single global registryself.config = dict(config)copies the caller's dict, preventing aliasing__init_subclass__is a hook called by Python automatically whenever a class inherits fromPluginBase- no manualregister()call needed
Key Takeaways
- A Python class is a live object - an instance of
type- not a static template - The class body executes immediately when Python encounters the
classstatement - Class attributes live in
Dog.__dict__; instance attributes live ininstance.__dict__ - Attribute resolution walks:
instance.__dict__→class.__dict__→ base class__dict__ self.attr += 1creates an instance attribute (it is an assignment);.append()on a class-level list mutates the shared object (it is not an assignment)- Mutable objects (lists, dicts, sets) at class level are shared across all instances - this is the shared mutable attribute trap
- The fix: always initialise mutable state in
__init__, never at class level selfis a convention, not a keyword - the first parameter of an instance method receives the instanceisinstance(obj, Class)is safer thantype(obj) == Classbecause it respects inheritance- Use
__slots__only when creating very large numbers of instances with a fixed attribute set
What's Next
Lesson 02 covers __init__ and object construction in depth - including __new__, the two-phase creation process, and super().__init__() in inheritance chains.
Understanding __init__ at this level is the prerequisite for everything that follows: dunder methods, inheritance, dataclasses, and the entire framework ecosystem you will encounter in production Python.
