A class is a blueprint for creating objects. Think of it like a cookie cutter — the class defines the shape, and every cookie we stamp out is an instance (an actual object in memory).
The class Keyword
We define a class with the class keyword. By convention, class names use PascalCase.
class Dog:
species = "Canis familiaris" # class attribute — shared by ALL dogs
def __init__(self, name, age):
self.name = name # instance attribute — unique to each dog
self.age = age
What Is __init__?
__init__ is the initializer, not the constructor. The actual constructor is __new__ (which creates the object). __init__ just sets up the initial state after the object already exists.
In simple language, __init__ is where we say “okay, this new object should have these attributes with these values.”
What Is self?
Every instance method receives self as its first argument. It’s a reference to the current instance. In simple language, self is how the object talks about itself — “my name”, “my age”.
Python passes self automatically — we never need to pass it ourselves.
class Dog:
def __init__(self, name):
self.name = name # "my name is whatever was passed in"
def bark(self):
return f"{self.name} says Woof!" # "my name says Woof!"
buddy = Dog("Buddy")
print(buddy.bark()) # Buddy says Woof!
Creating Instances
We call the class like a function. Python creates the object with __new__, then runs __init__ on it.
buddy = Dog("Buddy")
rex = Dog("Rex")
print(type(buddy)) # <class '__main__.Dog'>
print(isinstance(buddy, Dog)) # True
Instance vs Class Attributes
- Class attributes live on the class itself. Every instance shares them.
- Instance attributes live on each individual object. Each instance gets its own copy.
class Dog:
species = "Canis familiaris" # class attribute
def __init__(self, name):
self.name = name # instance attribute
buddy = Dog("Buddy")
rex = Dog("Rex")
print(buddy.species) # Canis familiaris — found on the class
print(rex.species) # Canis familiaris — same shared value
Attribute Lookup Order
When we access obj.attr, Python looks in this order:
- The instance
__dict__(instance attributes) - The class
__dict__(class attributes and methods) - Parent classes (following the MRO)
If nothing is found anywhere, we get an AttributeError.
Shadowing Class Attributes
When we assign to an instance, we create a new instance attribute that shadows the class one. The class attribute stays unchanged for everyone else.
buddy.species = "Robot Dog" # creates instance attribute
print(buddy.species) # Robot Dog — instance wins
print(rex.species) # Canis familiaris — class attribute untouched
Peeking Inside: __dict__
Every object has a __dict__ that holds its attributes as a dictionary. This is super useful for debugging.
print(buddy.__dict__) # {'name': 'Buddy', 'species': 'Robot Dog'}
print(Dog.__dict__) # {'species': 'Canis familiaris', '__init__': ..., ...}
type() and isinstance()
type(obj)tells us the exact class of an object.isinstance(obj, cls)checks if an object is an instance of a class or any of its subclasses. This is almost always what we want.
print(type(buddy)) # <class '__main__.Dog'>
print(type(buddy) == Dog) # True
print(isinstance(buddy, Dog)) # True
print(isinstance(buddy, object)) # True — everything inherits from object
In simple language, a class is a template, and an instance is a real object built from that template. Python looks for attributes on the instance first, then the class, then parent classes. That lookup order is the key to understanding everything else in Python OOP.