Lesson 9 of 16 · Level 3: Advanced

Python Classes and Objects: Object-Oriented Programming

  • ⏱ 13 min read
  • ❓ 4 review questions
  • ✍️ Exercise with solution
In this lesson
  1. Class and object
  2. Your first class
  3. Displaying objects nicely: __str__
  4. Encapsulation: protecting the data
  5. Inheritance: reuse and specialize
  6. dataclass: data classes without the boilerplate
  7. When should you use classes?
  8. Real case: online orders at a Chicago pizzeria
  9. Common mistakes with classes
  10. Summary
What you'll learn
  • Understand what classes and objects are, and when they're worth using.
  • Create your own classes with attributes and methods.
  • Use inheritance and the special methods __init__ and __str__.
  • Write data classes in a few lines with dataclass.
  • Model a real case: online orders at a Chicago pizzeria.

Welcome to the Advanced level. So far you've organized programs with functions and stored data in dictionaries. That works, but as a program grows, problems show up: misspelled dictionary keys, loose functions that all operate on the same data, repeated logic. Object-oriented programming (OOP) bundles data and the functions that manage it into a single unit: the object. Almost every library you'll use, from pandas to scikit-learn, is written this way.

1 → ∞a class is a blueprint you can use to create as many objects as you need
__init__runs automatically every time you create an object and sets up its data
@dataclasswrites the constructor, a readable display and comparisons for a data class

Class and object

  • A class is a blueprint: it defines what data something has and what it can do. For example, "Bank account."
  • An object (or instance) is a specific thing built from that blueprint: Jane's account, Carlos's account.

You've already used objects without knowing it: every string is an object of the str class, which is why it has methods like .upper().

Your first class

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            print("Insufficient funds")
            return False
        self.balance -= amount
        return True


janes_account = BankAccount("Jane", 500)
carlos_account = BankAccount("Carlos")

janes_account.deposit(200)
janes_account.withdraw(1000)   # Insufficient funds
print(janes_account.balance)   # 700
print(carlos_account.balance)  # 0

The key pieces:

  • class BankAccount: defines the class. By convention, class names use CamelCase (words joined, each capitalized).
  • __init__ is the constructor: it runs when the object is created and sets up its data.
  • self is the object itself. self.balance means "the balance of this account." Python passes it automatically: you write janes_account.deposit(200), not deposit(janes_account, 200).
  • Attributes (owner, balance): each object's data.
  • Methods (deposit, withdraw): the functions that belong to the class.

1. What is self for inside a method?

  • Importing modules.
  • Creating a global variable.
  • Referring to the specific object the method was called on.

self.balance is the balance of that particular object; every account has its own.

Displaying objects nicely: __str__

If you run print(janes_account) you'll see something like <__main__.BankAccount object at 0x7f...>. To control how it's displayed, define __str__:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def __str__(self):
        return f"{self.owner}'s account: ${self.balance:,.2f}"


print(BankAccount("Jane", 1250.5))   # Jane's account: $1,250.50

Methods with double underscores on each side ("dunder" methods) tell Python how your object should behave in special operations: being displayed, compared, added, measured with len() and more.

Encapsulation: protecting the data

What stops someone from writing janes_account.balance = -5000? Technically, nothing. Python relies on conventions: an attribute that starts with an underscore (_balance) means "internal, don't touch it from outside." And with @property you can expose a read-only value:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self._transactions = []

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount
        self._transactions.append(("deposit", amount))


a = BankAccount("Jane")
a.deposit(100)
print(a.balance)      # 100
# a.balance = 999     → AttributeError: property 'balance' ... has no setter

Notice raise ValueError(...): instead of printing a message, the method raises an exception that the caller can catch with try/except (lesson 8). That's the professional way to signal an error.

2. By convention, what does an attribute named _balance signal?

  • It's for internal use by the class and shouldn't be changed from outside.
  • It's a constant.
  • Python deletes it when the program ends.

The leading underscore is a "private" convention. Python doesn't block access, but every developer gets the message.

Inheritance: reuse and specialize

A class can inherit from another: it gets all of its attributes and methods and can add or change some. A high-yield savings account is a bank account that also earns interest, quoted in the U.S. as an APY (annual percentage yield):

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, apy=0.04):
        super().__init__(owner, balance)
        self.apy = apy

    def add_monthly_interest(self):
        interest = self._balance * ((1 + self.apy) ** (1 / 12) - 1)
        self.deposit(round(interest, 2))


savings = SavingsAccount("Carlos", 10_000, apy=0.04)
savings.add_monthly_interest()
print(savings.balance)   # 10032.74
  • class SavingsAccount(BankAccount): inherits from BankAccount.
  • super().__init__(...) calls the parent's constructor so you don't repeat code.
  • The savings account can use deposit and balance without defining them.

Use inheritance when the relationship is "is a kind of": a savings account is a kind of bank account. If the relationship is "has a" (a customer has accounts), store one object inside another instead (composition).

3. What does super().__init__(owner, balance) do in the child class?

  • It creates a new object of the parent class.
  • It runs the parent class's constructor to set up the shared attributes.
  • It deletes the parent class's attributes.

That way the child class reuses the parent's setup and only adds its own attribute (apy).

dataclass: data classes without the boilerplate

When a class mostly holds data, the @dataclass decorator automatically generates __init__, a readable display and comparisons between objects:

from dataclasses import dataclass
from datetime import date


@dataclass
class Expense:
    date: date
    category: str
    description: str
    amount: float

    def is_large(self, limit=100):
        return self.amount > limit


e = Expense(date(2026, 10, 1), "food", "Lunch", 14.25)
print(e)              # Expense(date=datetime.date(2026, 10, 1), category='food', ...)
print(e.is_large())   # False

Compare that with the dictionaries in lesson 8's project: now a typo in a field name (e.categry) raises a clear error, AttributeError: 'Expense' object has no attribute 'categry'. Did you mean: 'category'?, instead of slipping through, and your editor autocompletes the names.

When should you use classes?

Use a classData + behavior
When
Several values that always travel together, plus operations on them: an account, an order, a product.
Also
When you need many objects of the same type, each with its own state, or variations of something (inheritance).
Growing programs
Functions are enoughShort processes
When
A short script that reads a file, calculates and prints a result.
Why
A class would add structure nobody needs. Not everything has to be a class.
Simple scripts

Real case: online orders at a Chicago pizzeria

A pizzeria in Chicago takes orders online and needs to know what to charge and where each order stands. An order has data (customer, neighborhood, items) and behavior (calculate the total, move to the next status): it's the perfect candidate for a class.

from dataclasses import dataclass


@dataclass
class MenuItem:
    name: str
    price: float


class Order:
    STATUSES = ["received", "in the oven", "on the way", "delivered"]
    DELIVERY_FEE = 4.99

    def __init__(self, customer, neighborhood, delivery=True):
        self.customer = customer
        self.neighborhood = neighborhood
        self.delivery = delivery
        self.items = []          # every order gets its own list
        self.status = "received"

    def add(self, item, quantity=1):
        self.items.append((item, quantity))

    def total(self):
        subtotal = sum(i.price * q for i, q in self.items)
        return subtotal + (self.DELIVERY_FEE if self.delivery else 0)

    def advance(self):
        i = self.STATUSES.index(self.status)
        if i == len(self.STATUSES) - 1:
            raise ValueError("The order was already delivered")
        self.status = self.STATUSES[i + 1]

    def __str__(self):
        return f"{self.customer:7} {self.neighborhood:12} ${self.total():6.2f} [{self.status}]"


pizza = MenuItem("Large deep-dish pizza", 24.99)
soda = MenuItem("Soda 2 L", 3.99)
knots = MenuItem("Garlic knots", 6.50)

o1 = Order("Kayla", "Wicker Park")
o1.add(pizza, 2)
o1.add(soda)
o2 = Order("Marcus", "Lincoln Park")
o2.add(pizza, 4)
o2.add(knots, 2)
o2.add(soda, 2)
o3 = Order("Lucia", "Pilsen", delivery=False)
o3.add(pizza)

o1.advance()
o1.advance()     # received → in the oven → on the way
o2.advance()
for o in [o1, o2, o3]:
    print(o)

Output:

Kayla   Wicker Park  $ 58.96 [on the way]
Marcus  Lincoln Park $125.93 [in the oven]
Lucia   Pilsen       $ 24.99 [received]
One class and three objects created from it The Order class defines the attributes and methods. Three objects come from it: o1 for Kayla at $58.96 on the way, o2 for Marcus at $125.93 in the oven, and o3 for Lucia at $24.99 received. class Order customer · neighborhood · items total() · advance() o1Kayla$58.96on the way o2Marcus$125.93in the oven o3Lucia$24.99received
  • The class 1 blueprintDefines what data an order has and what it can do. You write it once.
  • The objects 3 ordersEach keeps its own data and status. Advancing o1 doesn't change o2.
o1 = 2 × $24.99 + $3.99 + $4.99 delivery = $58.96. o3 pays no delivery fee because it was created with delivery=False (a pickup order).

Three details to notice. STATUSES and DELIVERY_FEE are class attributes: values shared by every order, written once. advance() protects the business rules: an order can't move past "delivered." If you add for _ in range(4): at the end of the program with o3.advance() indented on the next line, the fourth call raises a clear error instead of leaving the order in an impossible state:

Traceback (most recent call last):
  File "pizzeria.py", line 58, in <module>
    o3.advance()
    ~~~~~~~~~~^^
  File "pizzeria.py", line 31, in advance
    raise ValueError("The order was already delivered")
ValueError: The order was already delivered

This traceback has two levels, and you read it bottom up: the last line says what happened, the one above says which method raised it (advance, line 31), and the top one shows which line of your program called it (58).

4. If the Order class had items = [] as a class attribute (outside __init__) and you added a pizza to o1, what would o2.items contain?

  • An empty list, because each order is different
  • The same pizza, because every order shares that one list
  • An AttributeError

A class attribute exists once for all objects. With a list, that means every order appends to the same list. That's why self.items = [] goes inside __init__: each object gets its own.

Common mistakes with classes

Forgetting self in a method: def total():

Every method receives self first. The message is TypeError: Order.total() takes 0 positional arguments but 1 was given.

Writing items = [] in __init__ without self.

Without self. it's a local variable that disappears. Later you'll see AttributeError: 'Order' object has no attribute 'items'.

Writing the constructor with single underscores: _init_

It's two on each side: __init__. Otherwise Python says TypeError: Order() takes no arguments.

Changing internal attributes from outside: o1.status = "delivered".

Use the class's methods (advance()), which enforce the business rules.

Practice exercise: a shopping cart

Create a Product class (with @dataclass: name and price) and a Cart class with these methods:

  1. add(product, quantity=1)
  2. total(), which returns the total
  3. apply_discount(percent), which raises ValueError if the percent isn't between 0 and 50
  4. __str__, which shows the cart's details
See solution
from dataclasses import dataclass


@dataclass
class Product:
    name: str
    price: float


class Cart:
    def __init__(self):
        self._items = []          # list of (product, quantity)
        self._discount = 0

    def add(self, product, quantity=1):
        self._items.append((product, quantity))

    def apply_discount(self, percent):
        if not 0 <= percent <= 50:
            raise ValueError("Invalid discount")
        self._discount = percent

    def total(self):
        gross = sum(p.price * q for p, q in self._items)
        return round(gross * (1 - self._discount / 100), 2)

    def __str__(self):
        lines = [f"{q} x {p.name}: ${p.price * q:.2f}" for p, q in self._items]
        lines.append(f"TOTAL: ${self.total():.2f}")
        return "\n".join(lines)


cart = Cart()
cart.add(Product("Keyboard", 25.0), 2)
cart.add(Product("Mouse", 12.5))
cart.apply_discount(10)
print(cart)
2 x Keyboard: $50.00
1 x Mouse: $12.50
TOTAL: $56.25

$62.50 minus 10% is $56.25. Calling cart.apply_discount(80) raises ValueError: Invalid discount.

Summary

  • A class is a blueprint; an object is a specific instance with its own data.
  • __init__ sets up the object, self is the object itself and __str__ controls how it's displayed.
  • Use _attribute and @property to protect data, and raise to signal errors.
  • Use inheritance for "is a kind of" relationships and @dataclass for classes that mostly hold data.
  • Create mutable attributes like lists inside __init__ so each object gets its own.

Next lesson: Python Modules, Packages, pip and Virtual Environments. You'll organize projects like a pro: your own modules, external libraries with pip and one virtual environment per project.

🎓 Save your progress and get a free certificate

Create your account in 30 seconds, mark your lessons and download your certificate at the end.

Enroll for free