Classes.
Data that knows how to behave.
A dict holds data. A class holds data and the functions that operate on it. That's the entire point of object-oriented programming, and it's what makes large codebases readable. This lesson teaches you to define classes, use self and init, override dunders, and — most importantly — recognize when a class is the right tool and when a function is.
A class is defined with class Name: and instantiated by calling it: user = User("Alice", 30). The init method runs automatically on creation and sets up initial attributes. Every method receives self as its first parameter — the instance itself. Use user.greet() to call methods. Use self.name to access attributes. Prefer classes over dicts when the data has behavior, validation, or a fixed shape.
Before this lesson: you should be comfortable with functions, dicts, and modules. If not, read Lesson 4, Lesson 5, and Lesson 8 first.
🧊 Defining a class
Five lines create a real object with state and behavior.
# Define a class
class User:
def init(self, name, age):
self.name = name
self.age = age
def greet(self):
return f"Hello, {self.name}!"
def birthday(self):
self.age += 1
return self.age
# Create instances (objects)
alice = User("Alice", 30)
bob = User("Bob", 25)
# Access attributes
print(alice.name) # Alice
print(bob.age) # 25
# Call methods
print(alice.greet()) # Hello, Alice!
print(alice.birthday()) # 31
print(alice.age) # 31 — attribute changed
print(bob.age) # 25 — bob is unaffected
class vs instance
The class is the blueprint. Instances (objects) are the things you build from it. User is a class; alice is an instance. One class, many instances.
init runs on creation
User("Alice", 30) calls init(self, "Alice", 30) automatically. It sets up the initial state. It's not required, but it's where validation and setup go.
Methods are functions with self
def greet(self): — self is the instance. You call it as alice.greet(), not greet(alice). Python handles the passing automatically.
Instances are independent
Changing alice.age doesn't touch bob.age. Each object has its own copy of the instance attributes. That's the whole point — the state is per-object.
👤 self — the parameter you'll forget
Every method's first parameter. Never skip it. Never pass it manually.
class Counter:
def init(self):
self.count = 0
def increment(self):
self.count += 1
def get(self):
return self.count
c = Counter()
c.increment()
c.increment()
c.increment()
print(c.get()) # 3
# What self actually is:
# c.increment() is short for Counter.increment(c)
# c.get() is short for Counter.get(c)
# Common error: forgetting self in the definition
class Broken:
def greet(): # no self!
return "hi"
b = Broken()
# b.greet() → TypeError: greet() takes 0 positional arguments but 1 was given
# Python passes b as the first argument, but greet() doesn't accept it.
# The fix:
class Fixed:
def greet(self):
return "hi"
"takes 0 positional arguments but 1 was given" — this error means a method is missing its self parameter. Python always passes the instance as the first argument to a method. If the method signature doesn't accept it, you get this error. Add self as the first parameter and it works. That's the entire diagnosis.
self is a convention, not a keyword. You could name it this or me and Python would accept it. But every Python developer in the world expects self. Linters enforce it. Documentation assumes it. Use self — always, without exception.
✨ Dunder methods — make objects feel native
Customize how Python's built-in syntax behaves on your objects.
class Money:
def init(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
# repr — how the object shows in the REPL / print()
def repr(self):
return f"Money({self.amount}, '{self.currency}')"
# str — how it shows with str() (defaults to repr if not set)
def str(self):
return f"${self.amount:.2f} {self.currency}"
# eq — enables ==
def eq(self, other):
return self.amount == other.amount and self.currency == other.currency
# lt — enables < (needed for sorted())
def lt(self, other):
return self.amount < other.amount
# add — enables +
def add(self, other):
return Money(self.amount + other.amount, self.currency)
# len — enables len()
def len(self):
return len(str(self.amount))
m1 = Money(10)
m2 = Money(25)
print(m1) # $10.00 USD (uses str)
print(repr(m1)) # Money(10, 'USD') (uses repr)
print(m1 == m2) # False (uses eq)
print(m1 < m2) # True (uses lt)
print(m1 + m2) # $35.00 USD (uses add)
print(sorted([m2, m1])) # [Money(10), Money(25)] (uses lt)
init
Runs on creation. Sets initial state. What you've been using.
repr
The "developer view" — Money(10, 'USD'). Should be unambiguous. Used by print() in collections and by the REPL.
str
The "user view" — $10.00 USD. Used by print(m) and str(m). Falls back to repr if missing.
eq, lt, add
Make ==, <, and + work on your objects. Once you have lt, sorted(), min(), max() just work.
Always define repr. Even if you don't define str, always define repr. Without it, printing a list of your objects shows [ — useless. With it, you see [Money(10, 'USD'), Money(25, 'USD')] — instantly readable. This one method pays for itself on the first debugging session.
🌳 Inheritance — extend without rewriting
Powerful when the relationship is truly "is-a". Dangerous when it's not.
# Base class
class Animal:
def init(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
def describe(self):
return f"I am {self.name}"
# Child class — inherits everythingViewController
# Inherits everything from Animal, overrides speak()
class Dog(Animal):
def speak(self):
return f"{self.name} barks"
class Cat(Animal):
def init(self, name, indoor=True):
super().init(name) # call parent's init
self.indoor = indoor
def speak(self):
return f"{self.name} meows"
# Use them
a = Animal("Generic")
d = Dog("Rex")
c = Cat("Whiskers", indoor=False)
print(a.speak()) # Generic makes a sound
print(d.speak()) # Rex barks
print(c.speak()) # Whiskers meows
# Inherited methods work on children
print(d.describe()) # I am Rex (from Animal)
print(c.describe()) # I am Whiskers (from Animal)
# isinstance() checks the inheritance chain
print(isinstance(d, Dog)) # True
print(isinstance(d, Animal)) # True (Dog is an Animal)
print(isinstance(c, Dog)) # False
Child inherits everything
class Dog(Animal) — Dog gets init, speak, describe from Animal. No copy-paste needed.
Override what changes
Dog redefines speak(). That's the only difference. Everything else is inherited. Override the minimum.
super() calls the parent
super().init(name) in a child's init calls the parent's version. If you don't call it, the parent's setup never runs.
Composition often beats inheritance
A Car has an Engine, it isn't one. Use inheritance only for true "is-a" relationships. Otherwise store an instance as an attribute.
⚖️ When to use a class — and when not to
Most beginner code doesn't need classes. Recognizing when it does is the actual skill.
| Situation | Use | Why |
|---|---|---|
| Data from an API | Dict | Keys vary; you don't know shape upfront |
| Config file contents | Dict | Bag of key-value pairs, no behavior |
| A record with methods | Class | Has behavior (validate, save, compute) |
| A user account | Class | Fixed fields + validation + methods |
| A one-off helper | Function | No state to hold |
| Multiple instances needed | Class | Each instance has its own state |
The refactoring rule. Start with functions and dicts. If you find yourself writing def helper(user_dict): where user_dict is always the first argument, that's a class begging to be created. If you're passing the same 3-key dict to 5 functions, that's a class. Until then, functions are simpler. Don't add classes "for cleanliness" — add them when the data starts having behavior.
# Before: dict + free functions (a sign it wants to be a class)
def create_user(name, email):
return {"name": name, "email": email, "active": True}
def validate_email(user):
if "@" not in user["email"]:
raise ValueError("Invalid email")
return user
def deactivate(user):
user["active"] = False
def greet(user):
return f"Hello, {user['name']}"
# After: a class — everything the user can do is on the user
class User:
def init(self, name, email):
if "@" not in email:
raise ValueError("Invalid email")
self.name = name
self.email = email
self.active = True
def deactivate(self):
self.active = False
def greet(self):
return f"Hello, {self.name}"
# Now: u = User("Alice", "a@b.com"); u.deactivate(); u.greet()
# The data and behavior live together. That's the refactor.
💻 A complete example
A realistic class with validation, methods, and dunders.
class BankAccount:
"""A bank account with validation and transaction history."""
def init(self, owner, balance=0):
if balance < 0:
raise ValueError("Opening balance can't be negative")
self.owner = owner
self.balance = balance
self.history = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
self.history.append(f"+$`{amount:.2f}")
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self.balance:
raise ValueError(f"Insufficient funds: balance ${self.balance:.2f}"</span>) self.balance -= amount self.history.<span class="fn">append</span>(<span class="str">f"-${amount:.2f}")
def repr(self):
return f"BankAccount('{self.owner}', balance=`${self.balance:.2f})"
def str(self):
return f"{self.owner}'s account: $`{self.balance:.2f}"
def eq(self, other):
return self.owner == other.owner and self.balance == other.balance
# Use it
acct = BankAccount("Alice", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct) # Alice's account: $120.00</span> <span class="fn">print</span>(<span class="fn">repr</span>(acct)) <span class="com"># BankAccount('Alice', balance=$120.00)
print(acct.history) # ['+$50.00', '-$30.00']
# Validation catches mistakes
try:
acct.withdraw(1000)
except ValueError as e:
print(f"Error: {e}") # Error: Insufficient funds: balance `$120.00
Notice the pattern. All validation lives in the class. There's no way to create an invalid BankAccount — the constructor rejects negative balances, deposit rejects zero and negative, withdraw rejects overdrafts. Compare that to a dict: {"owner": "Alice", "balance": -500} is perfectly valid and nothing stops it. That's the entire argument for classes: they let you enforce invariants that a dict can't.
🏆 Challenge
Three classes. Each teaches a different aspect.
Task 1 — Rectangle
Define a Rectangle class with width and height. Add methods: area(), perimeter(), and is_square() (returns True if width equals height). Add a repr so Rectangle(4, 5) prints as Rectangle(4, 5).
Test: Rectangle(4, 5).area() → 20. Rectangle(4, 4).is_square() → True.
Task 2 — Todo item
Define a Todo class with text, done=False, and created (default datetime.now()). Add complete() that sets done = True. Add str so printing a completed item shows [✓] Buy milk and an incomplete one shows [ ] Buy milk.
Test: create two todos, complete one, print both — verify the checkmark appears on only one.
Task 3 — Bank account (extended)
Take the BankAccount example from the walkthrough. Add:
1. A transfer_to(other, amount) method that withdraws from self and deposits into other — should be atomic (if the deposit fails, the withdrawal shouldn't happen).
2. A statement() method that prints the account owner and each transaction.
3. A lt method so acct1 < acct2 compares balances. Then verify sorted([acct1, acct2, acct3]) works.
Test: two accounts, transfer between them, verify balances and history are correct.
⚠️ Common mistakes
The class bugs that catch every beginner.
def greet(): inside a class raises TypeError: takes 0 positional arguments but 1 was given. Fix: def greet(self):. Python passes the instance automatically — the method must accept it.
class User: roles = [] — the list is shared across all instances. Adding a role to one adds to every instance. Fix: define mutable attributes in init: self.roles = [].
User.greet() requires an instance as the first argument. Fix: create an instance first — User("Alice").greet(). Or if you truly need a class method, use @classmethod.
In a child class, if you define init, the parent's init doesn't run automatically. Fix: call super().init(...) first. Otherwise the parent's setup (like setting self.name) never happens.
If you define eq to compare by value, Python sets hash to None, making the object unhashable. You can't put it in a set or use it as a dict key. Fix: define hash too, usually return hash((self.attr1, self.attr2)).
self.x reads the instance attribute if it exists, else the class attribute. Assigning self.x = 5 always creates an instance attribute — it never modifies the class. This gets confusing with mutable class attributes.
Just because you can wrap every function in a class doesn't mean you should. If a function has no state and only takes arguments, it should be a function. Overusing classes makes code more complex, not less.
If your class hierarchy is 5 levels deep, adding a new class requires understanding 5 parents. Fix: prefer composition. A Car has an Engine as an attribute — it doesn't need to inherit from a Vehicle that inherits from a Machine that inherits from a PhysicalObject. Two levels max is a good rule.
🎓 Python track complete
Ten lessons. A full arc from "hello world" to object-oriented code.
You've written real Python
Variables, lists, dicts, loops, functions, files, error handling, modules, projects, classes. That's a genuinely useful skillset, not a superficial tour.
You've shipped a project
The expense tracker in Lesson 9 is a real CLI tool — not a toy. That's the difference between "learning Python" and "knowing Python."
You can read real codebases
Open any small Python project on GitHub and follow along. Classes, methods, dunders — the syntax is now familiar. You can read, and reading is how you write better.
You know what you don't know
Testing (pytest), type hints (mypy), async (asyncio), web (Flask, Django), data (pandas, numpy). The rest is discovery — no more prerequisites.
What to build next. Take the expense tracker and extend it with a web interface using Flask. Or build a scraper that reads 100 web pages and summarizes them. Or take the BankAccount class and add add, sub, and JSON serialization, then build a mini banking system. The point isn't the project — it's that you now finish them. That's the habit the track was teaching all along.
➡️ What's next
The Python track ends here. Three directions from this point.
❓ Frequently asked questions
What is a class in Python?
A class is a blueprint for creating objects. It bundles data (attributes) and behavior (methods) into a single reusable type. You define it with the class keyword, then create instances by calling the class like a function: user = User('Alice', 30). Unlike dicts, which only hold data, a class holds data AND the functions that operate on that data — that's the entire point.
What is self in Python classes?
self is the instance the method is called on. When you write def greet(self):, Python passes the instance automatically — user.greet() is really User.greet(user). You need self as the first parameter of every method (except staticmethods and classmethods) so you can access that instance's attributes with self.name. It's a convention, not a keyword — but use self; every Python developer expects it.
What is init in Python?
init is the initializer — it runs automatically when you create an instance. You use it to set up the object's initial state: def init(self, name): self.name = name. It's not a constructor (new is) — by the time init runs, the object already exists. You rarely need new. Use init for validation and attribute assignment.
What is the difference between a class attribute and an instance attribute?
Class attributes are defined at the class level and shared by all instances — good for constants or defaults. Instance attributes are set in init (or later) and belong to one object. Classic bug: defining a mutable class attribute like items = [] means all instances share the same list. Fix: define list attributes in init with self.items = []. Rule: immutable shared state can be a class attribute; mutable shared state never should be.
When should I use a class instead of a dict?
Use a class when the data has behavior (methods that operate on it), when you need to enforce validation, when the shape is fixed and known, or when you want IDE autocomplete and type checking. Use a dict when the data is a bag of key-value pairs, when keys vary at runtime, or when you're passing data to/from an API. Rule of thumb: if you find yourself writing helper functions that take the dict as their first argument, it should probably be a class.
What is inheritance in Python?
Inheritance lets a class (child) reuse and extend another class (parent). class Dog(Animal): means Dog gets all attributes and methods of Animal, then adds its own. Use super() to call the parent's methods: super().init(name). Override a method by redefining it in the child. Prefer composition over inheritance when the relationship isn't truly is-a — a Car has an Engine, it isn't an Engine.
What is the difference between @property and a regular attribute?
A @property is a method that's accessed like an attribute — user.full_name (no parentheses). You use it when you want to compute a value on access (derived from other attributes) or when you want to add validation without breaking the API. @property def full_name(self): return f'{self.first} {self.last}'. Add user.full_name = 'x' via a @full_name.setter to support assignment. It gives you method flexibility with attribute syntax.
What are dunder methods in Python?
Dunder ('double underscore') methods customize how objects behave with Python syntax. init runs on creation, repr controls what print() shows, eq controls ==, len controls len(), iter enables for loops, str controls str(). Implementing them means your custom classes feel native — a Card class with repr prints nicely, a Deck class with len works with len(). The Python data model is built on dunders.