Lesson 10

Classes

Home › Courses › Python › Lesson 10
🐍 Lesson 10 · 20 minutes

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.

~20 minutes Intermediate Python 3.x
Python path Lesson 8 Define self Dunders Inheritance When to use Challenge

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.

Python
# 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.

Python
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.

Python
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.

Python
# 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 APIDictKeys vary; you don't know shape upfront
Config file contentsDictBag of key-value pairs, no behavior
A record with methodsClassHas behavior (validate, save, compute)
A user accountClassFixed fields + validation + methods
A one-off helperFunctionNo state to hold
Multiple instances neededClassEach 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.

Python
# 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.

Python
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.

1. Forgetting self in method definitions

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.

2. Mutable class attributes

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 = [].

3. Calling methods on the class instead of the instance

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.

4. Not calling super().init

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.

5. Overriding eq without hash

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)).

6. Confusing class and instance attribute access

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.

7. Using classes for everything

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.

8. Deep inheritance chains

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.

🌐 External resources

← Back to Python track
You're in the Python track · Lesson 10 · Track complete