Classes and Objects — The Basics
What a class actually is, what an object actually is, how self really works under the hood, and when object-oriented Python genuinely earns its complexity.
Object-Oriented Python Starts Here
Everything in this track up to this point — variables, control flow, strings, lists, dicts, and especially functions in Module 07 — was building toward this phase. Object-oriented programming is not a separate topic bolted onto Python; it is the natural next step once you have functions that operate on data and you start noticing the same clusters of data and functions traveling together everywhere in your code. A shopping cart. A user account. A bank transaction. Each of those is really a bundle of related data (what it has) plus related behavior (what it can do). Phase 3, starting with this module, is about learning Python's tool for expressing that bundle explicitly: the class.
You have actually been using classes this entire time without necessarily thinking of them that way. Every string is an instance of the built-in str class. Every list is an instance of list. When you call "hello".upper(), you are calling a method defined on the str class, and "hello" is the object that method runs against. This module pulls back the curtain on that mechanism and shows you how to build your own classes, from scratch, that work the exact same way.
A Class Is a Blueprint. An Object Is What Gets Built From It.
The cleanest mental model: a class is a blueprint — it describes what something looks like and what it can do, but it is not, itself, a real thing you can use. An object (also called an instance) is a real, concrete thing built from that blueprint. A blueprint for a house is not a house you can live in — it is the plan. Every actual house built from that blueprint is a separate, independent instance, each with its own address, its own paint color, its own furniture, even though they share the same underlying design.
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def bark(self):
return f"{self.name} says Woof!"Dog by itself is just a definition sitting in memory — no dog exists yet. You create actual dog objects by calling the class, exactly like calling a function:
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Poodle")
print(rex.bark()) # Rex says Woof!
print(bella.bark()) # Bella says Woof!
print(type(rex)) # <class '__main__.Dog'>
print(isinstance(rex, Dog)) # Truerex and bella are two completely independent objects. Each has its own name and breed, stored separately. Changing rex.name has zero effect on bella.name — they just happen to have been built from the same blueprint. This independence is the entire point of a class: define the shape once, then stamp out as many independent instances as you need.
Anatomy of a Class Definition
A class definition starts with the class keyword, a name (conventionally PascalCase, not snake_case — this is one of the few places Python style genuinely differs between variables/functions and classes), and a colon, followed by an indented block exactly like a function or an if statement.
class Dog: # PascalCase class name
species = "Canis familiaris" # a class attribute (Part 02 of the next module covers this properly)
def __init__(self, name, breed): # the constructor — runs automatically when you create an object
self.name = name # an instance attribute
self.breed = breed # another instance attribute
def bark(self): # a regular method
return f"{self.name} says Woof!"
def describe(self): # methods can call other methods through self
return f"{self.name} is a {self.breed}. {self.bark()}"Everything indented under class Dog: is part of the class body. Functions defined inside that body are called methods — they are functions that belong to the class and operate on individual instances. The naming convention matters more than it might seem: seeing Dog, PaymentProcessor, or OrderValidator in code immediately signals "this is a class" to any experienced Python reader, purely from the capitalization, before they even see a class keyword nearby.
__init__ Runs Automatically Every Time You Create an Object
__init__ (pronounced "dunder init," short for double-underscore init) is a special method Python calls automatically the moment you create a new instance of a class. It is where you set up whatever state that instance needs to start its life with. It is often called "the constructor," though technically Python has a separate, rarely-touched method called __new__ that does the actual object creation — __init__ just initializes the object after it already exists. You will not need __new__ for ordinary application code, and it is out of scope for this track.
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
rex = Dog("Rex", "German Shepherd")
# Python does roughly this, automatically, behind the scenes:
# 1. Create a new, empty Dog object
# 2. Call Dog.__init__(that_new_object, "Rex", "German Shepherd")
# 3. Bind the name "rex" to the now-initialized objectNothing forces you to define __init__. A class with no __init__ is completely valid — you simply get an object with no instance attributes set up automatically. In practice, though, almost every real class you write will have one, because almost every real object needs some starting state.
return statement with a value (Python raises a TypeError if you try to return anything other than None from it). Its entire job is to set up self — the object being created — not to produce a result the way a normal function does.Why self Is Explicit in Python — The Actual Mechanism
If you have touched Java, C#, or JavaScript, you have seen this — a keyword that refers to the current object inside a method, but that appears "for free," implicitly, without being declared as a parameter. Python does the equivalent job with self, but with one deliberate difference: self is not magic and it is not a keyword. It is an ordinary parameter, written explicitly as the first parameter of every instance method, and Python fills it in for you automatically when you call the method through an object.
Here is the actual mechanism, not just an analogy. A method defined inside a class is, under the hood, just a regular function stored on the class. When you write rex.bark(), Python does not magically know which dog you mean — it translates that call into Dog.bark(rex), passing rex in as the first argument. The parameter you conventionally name self is simply where that first argument lands. You could technically name it anything — self is a universal convention, not a rule enforced by the interpreter — but every Python codebase you will ever work in uses self, and deviating from it will draw immediate code review pushback.
class Dog:
def bark(self):
return f"{self.name} says Woof!"
rex = Dog()
rex.name = "Rex"
rex.bark() # "Rex says Woof!" — the usual, idiomatic way to call it
Dog.bark(rex) # "Rex says Woof!" — IDENTICAL call, calling the function on the class directly,
# and passing rex in manually as the first argumentThis is why self must be declared explicitly as a parameter in every method definition, even though you never pass it explicitly when calling instance.method() — Python's dot-call syntax (rex.bark()) is what quietly inserts rex as the first argument for you. It is genuinely just "the object this method was called on," made visible as an ordinary parameter instead of hidden behind special-case syntax the way this is in other languages. Once this clicks, a huge amount of Python's object model stops feeling like magic and starts feeling like straightforward function calls with one extra, automatically-supplied argument.
def bark(): instead of def bark(self): inside a class will raise a TypeError the moment you call it as rex.bark(), because Python is still trying to pass rex in as an argument, but the function signature has no parameter to receive it. The exact error message is covered in the Error Library at the end of this module.self.attribute — Data That Belongs to One Specific Object
Any attribute you set with self.something = value inside a method becomes an instance attribute — data that lives on that one specific object, completely separate from every other instance of the same class. You are not limited to setting instance attributes inside __init__, though that is by far the most common and most readable place to do it, since it means every attribute the object will ever have is visible in one place, right where the object is created.
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
self.tricks = [] # every dog starts with its own empty list
def learn_trick(self, trick):
self.tricks.append(trick)
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Poodle")
rex.learn_trick("sit")
rex.learn_trick("roll over")
print(rex.tricks) # ['sit', 'roll over']
print(bella.tricks) # [] — completely unaffected by what happened to rexYou can also read and write instance attributes directly from outside the class, using dot notation on the object — rex.name, rex.tricks. Nothing in plain Python prevents this by default (that is exactly the subject of encapsulation, Module 22, later in this phase). For now, treat instance attributes as an object's own private notebook of facts about itself.
Methods Reading and Changing an Object's Own State
A method is genuinely useful once it does more than just read self — it can change the object's state, and that change persists on the object for as long as it exists. This is the core value proposition of a class: bundling data with the behavior that knows how to change that data correctly, instead of scattering both across loose variables and standalone functions.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError(f"Insufficient funds: balance is {self.balance}")
self.balance -= amount
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self.balance})"
alice_account = BankAccount("Alice", 500)
bob_account = BankAccount("Bob", 100)
alice_account.deposit(250)
bob_account.withdraw(50)
print(alice_account) # BankAccount(owner='Alice', balance=750)
print(bob_account) # BankAccount(owner='Bob', balance=50)Notice each account genuinely manages its own balance — deposit() and withdraw() can never accidentally touch the wrong account, because every call is scoped to the specific object it was called on (alice_account.deposit(...) only ever touches alice_account). Creating a hundred more accounts costs nothing conceptually — each is independent, automatically, just by virtue of being a separate object. This is what "instance" really buys you: as many independent, self-consistent copies of the blueprint as your program needs, each safely isolated from the others.
__repr__ above is a small preview of dunder methods, covered properly in Module 22. For now, just know it is why print(alice_account) shows a readable summary instead of Python's default, fairly useless <__main__.BankAccount object at 0x7f...>.When OOP Genuinely Earns Its Complexity — An Honest Take
A class is not automatically the right tool just because Python has one available. Reaching for a full class when a simpler structure would do is a real, common source of unnecessary complexity in junior engineers' code — and this track will not pretend otherwise just because it is teaching OOP right now. It is worth being honest about when a class earns its keep and when it does not.
When a dict or namedtuple is genuinely the better choice
If you just need to group a few related values together with no behavior attached — no methods, no validation, no invariants to protect — a plain dict or a collections.namedtuple (or, once you reach later modules, a dataclass) is simpler, requires less boilerplate, and is exactly as readable.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
# This is genuinely simpler and does the same job:
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4When a class genuinely earns its complexity
A class starts paying for itself the moment you need behavior tied to data (methods that operate on the object's own state, like deposit() above), invariants to protect (rules that must always hold true, like a balance that can never legally go negative), or multiple related pieces of state that change together over the object's lifetime. The BankAccount example above is a genuine case for a class: a dict version of the same thing would let any code accidentally set account["balance"] = -500 directly, bypassing the validation entirely, since a plain dict has no way to enforce a rule about how its own values are allowed to change.
A Denver Ride-Share Startup Rewrites Its Trip-Tracking Dict
A ride-share startup's early prototype tracked every active trip as a plain dictionary passed between functions — start_trip(), update_location(), end_trip(), each one reaching in and reading or mutating specific keys.
def start_trip(driver_id, rider_id, pickup):
return {
"driver_id": driver_id,
"rider_id": rider_id,
"pickup": pickup,
"status": "in_progress",
"fare": 0,
}
def update_location(trip, new_location):
trip["current_location"] = new_location
trip["fare"] += 0.50 # every update tacks on distance-based fare
def end_trip(trip):
trip["status"] = "completed"
return trip["fare"]What went wrong as the codebase grew
Within a few months, a bug landed in production: some code path called update_location() on a trip after it had already been ended, silently adding fare to a trip that should have been closed. Nothing in a plain dict enforced that a completed trip could not still be modified — every function trusted every caller to behave correctly, and eventually one did not.
The fix — exactly what Part 08 describes
The team rewrote Trip as a real class. update_location() became a method that could check self.status before doing anything, and raise if the trip was already completed — an invariant a dict has no mechanism to protect, but a class with real methods enforces automatically, every single time, regardless of which part of the codebase calls it.
class Trip:
def __init__(self, driver_id, rider_id, pickup):
self.driver_id = driver_id
self.rider_id = rider_id
self.pickup = pickup
self.status = "in_progress"
self.fare = 0
def update_location(self, new_location):
if self.status != "in_progress":
raise ValueError("Cannot update location on a trip that has already ended")
self.current_location = new_location
self.fare += 0.50
def end(self):
self.status = "completed"
return self.fareThis is exactly the "invariants to protect" case from Part 08 — the moment a rule needed enforcing across every call site, the dict stopped being the right tool, and the class paid for its extra boilerplate many times over the first time it caught a bug that would otherwise have shipped.
Four Misconceptions About Classes and Objects
5 Interview Questions — With Complete Answers
Class Mistakes Beginners Make Constantly
Errors You Will Hit With Classes — And Exactly Why
🎯 Key Takeaways
- ✓A class is a blueprint; an object (instance) is a concrete thing built from it. Every instance has its own independent copy of the instance attributes the class defines.
- ✓__init__ runs automatically when you create an object, and is where you set up its initial state — but it never returns a value other than None.
- ✓self is not magic and not a keyword — it is an ordinary parameter that Python automatically fills in with the object a method was called on. rex.bark() is really Dog.bark(rex) under the hood.
- ✓Instance attributes are set with self.attribute = value, most commonly inside __init__, and persist for the object's whole lifetime.
- ✓Multiple instances of the same class are fully independent — changing one instance's attributes never affects another instance, even though they share the same class and methods.
- ✓A class is not always the right tool. If there is no behavior attached and no invariants to protect, a dict, namedtuple, or dataclass is simpler.
- ✓Classes earn their complexity once methods need to read or change an object's own state, or once a rule about that state needs enforcing everywhere it is touched.
- ✓By convention, class names use PascalCase, distinguishing them at a glance from snake_case variables and functions.
What comes next
Module 20 goes deeper on __init__, and covers the single most infamous gotcha in object-oriented Python: what happens when a class attribute is a mutable object, shared silently across every instance until you know to watch for it.
Module 20 → Constructors, Instance vs Class AttributesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.