Until now your data and your functions have lived apart: dictionaries over here, the functions that operate on them over there, and nothing but discipline keeping them consistent. Object-oriented programming joins them. A class bundles data and the functions that belong to it into one named unit, and once you see it, you will recognize it everywhere, because nearly everything you have already used is an object: strings with their .upper(), lists with their .append(), files, and every library you will import for the rest of this course. This lesson does not teach you a new world; it teaches you the rules of the world you have been living in.
A word of reassurance, because OOP is where many self-taught learners stall. The ideas are few: a class is a blueprint, an object is one thing built from it, methods are functions that live on the blueprint, and self is how a method refers to the particular object it was called on. Every confusing sentence ever written about OOP unwinds into those four facts. We will go slowly, build something real, and finish with dataclasses, the modern shortcut that removes most of the boilerplate.
What you will learn in Part 6
- Classes and instances: blueprints versus the things built from them
- The __init__ method and what self actually is
- Instance methods, and attributes versus local variables
- String representation with __str__ and friends
- Inheritance and super(), and when composition beats it
- Dataclasses: modern Python records with almost no boilerplate
Note
Before you start
You need functions from Part 4 and dictionaries from Part 5 down cold, because a class is best understood as a dict of data plus the functions that own it. The Object and Class lesson in the Learn Python app pairs with this part.
1. Your first class
Here is a bank account, the classic teaching example, chosen because the rules write themselves: an account has an owner and a balance, you can deposit and withdraw, and you must not withdraw more than the balance. The class statement names the blueprint; by convention class names use CapWords rather than snake_case. The __init__ method runs automatically whenever a new object is created, and its job is to attach starting data to the object. The first parameter of every method is the object itself, received under the conventional name self.
class BankAccount:
"""A simple account with deposits and protected withdrawals."""
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(f"Insufficient funds for {self.owner}")
return False
self.balance -= amount
return True
acct = BankAccount("Amina", 1000) # __init__ runs here
acct.deposit(500)
acct.withdraw(2000) # Insufficient funds for Amina
acct.withdraw(300)
print(f"{acct.owner}: {acct.balance}") # Amina: 1200
Read the creation line carefully: BankAccount("Amina", 1000) builds a fresh object, then calls __init__ with that object as self, "Amina" as owner, and 1000 as balance. The two assignments inside __init__ attach attributes to that particular object. When you later call acct.deposit(500), Python quietly rewrites it as BankAccount.deposit(acct, 500); that is the entire mystery of self, solved. It is not a keyword, just the first parameter, and the dot syntax fills it in for you.
Make a second account and the point of classes lands: each object carries its own attributes. acct and a new BankAccount("Zane") are two independent records with the same behavior, exactly like two dictionaries that share one set of functions, except the bundling is now enforced by the language instead of by your discipline. The data and its rules cannot drift apart, because they live at the same address.
2. Telling Python how to print your objects
Print acct right now and you get something like <__main__.BankAccount object at 0x7f3a...>, which is true but useless. Methods whose names are wrapped in double underscores, called dunder methods, let your class plug into Python's built-in behavior, and __str__ is the one to learn first: it returns the string that print and f-strings should use. Its sibling __repr__ is the unambiguous developer-facing version shown in error messages and the REPL. Define at least one of them in every class you write; debugging an object you cannot read is misery on a deadline.
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:,}"
def __repr__(self):
return f"BankAccount({self.owner!r}, {self.balance})"
acct = BankAccount("Amina", 125000)
print(acct) # Amina's account: 125,000
print([acct]) # [BankAccount('Amina', 125000)] lists use repr
There are many more dunders, __len__ to support len(), __eq__ to define equality, __add__ to support +, and they are why 2 + 3, "a" + "b", and [1] + [2] can all use one operator with type-appropriate meanings. You do not need to memorize the catalog. You need to know it exists, so that when a library object behaves magically with built-in syntax, you recognize ordinary methods with special names rather than actual magic.
Checkpoint
What is self in a Python method?
Two refinements complete your picture of attributes. First, classes can carry data of their own: an attribute assigned at class level, outside any method, is shared by every instance, the right home for constants like a minimum balance or an interest cap, and the wrong home for per-object state, which is the trap listed in the mistakes box below. Second, when an attribute later needs logic behind it, validation, computation, caching, the @property decorator converts a method into something accessed like an attribute, so account.balance can become computed without changing a single caller. You do not need to write properties this month; you need to recognize them, because library objects use them everywhere.
It is also worth saying out loud that none of this is new machinery; it is the machinery you have been using since Part 1. "hello".upper() is a method call on an instance of the str class, [1, 2].append(3) is a method on a list instance, and the file objects of Part 7 are instances whose with-behavior is just dunder methods. Writing your own classes is joining a system, not learning a second language, and that realization is the moment OOP stops feeling like a topic and starts feeling like the ground.
3. Inheritance: building on an existing class
Inheritance lets a new class start from an existing one and change only what differs. A SavingsAccount is a BankAccount plus an interest rate; writing class SavingsAccount(BankAccount) gives the child every attribute and method of the parent for free. The child can add new methods, and it can override inherited ones by redefining them. Inside an override, super() reaches the parent's version, which is exactly how a child extends behavior instead of replacing it wholesale.
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, rate=0.04):
super().__init__(owner, balance) # let the parent do its setup
self.rate = rate
def add_interest(self):
interest = round(self.balance * self.rate, 2)
self.deposit(interest) # reuse inherited behavior
return interest
s = SavingsAccount("Zane", 10_000)
earned = s.add_interest()
print(f"Interest {earned}, new balance {s.balance}")
# Interest 400.0, new balance 10400.0
print(isinstance(s, BankAccount)) # True: a SavingsAccount IS a BankAccount
Inheritance is powerful and routinely overused. The test is the phrase is a: a SavingsAccount is a BankAccount, so inheritance fits. A Car has an Engine, it is not one, so the engine should be an attribute, a design called composition. When in doubt, prefer composition; deep inheritance trees are where codebases go to become unmaintainable, and most professional Python uses shallow hierarchies or none at all. You will mostly consume inheritance rather than design it, subclassing a framework's base class exactly one level deep.
4. Dataclasses: records without the ritual
A great deal of OOP in practice is just records: a thing with named fields, maybe a method or two. Writing __init__, __repr__, and __eq__ by hand for every record is ritual, so modern Python automates it. Decorate a class with @dataclass, declare the fields with type hints (a preview of Part 10), and the boilerplate is generated for you. Since their arrival, dataclasses have become the default way to model plain data in Python, and you will see them across the entire ecosystem.
from dataclasses import dataclass
@dataclass
class Student:
name: str
score: int
city: str = "Colombo"
def grade(self):
return "A" if self.score >= 75 else "B" if self.score >= 65 else "C"
a = Student("Amina", 87)
z = Student("Zane", 91, city="Galle")
print(a) # Student(name='Amina', score=87, city='Colombo')
print(a == Student("Amina", 87)) # True: field-by-field equality, free
print(z.grade()) # A
One dataclass detail earns its mention because it closes a trap you already know: mutable defaults. A field like tags: list = [] would recreate the Part 4 shared-list bug at class scale, so dataclasses refuse it outright and provide the idiom field(default_factory=list), which builds a fresh list per instance. The same factory pattern serves dicts and sets, and meeting it here means the error message, when you eventually trigger it, will read like an old friend rather than a riddle.
Compare that to the hand-written equivalent, three dunder methods and a dozen lines, and the appeal is obvious. Dataclasses still accept ordinary methods, defaults, and everything classes do; they simply generate the repetitive parts. When you later meet Pydantic in the FastAPI series, you will recognize this exact pattern extended with validation; the Pydantic v2 deep dive is the natural sequel to this section once you finish the track.
Checkpoint
When should you reach for inheritance?
5. Practice: a tiny inventory system
The playground below sketches a product inventory: a dataclass for products and a regular class for the inventory that manages them, composition in action. The exercises ask you to add a method, an override, and a dunder, the three moves this lesson taught. Build first, read the solution hints second; struggling for five minutes is the part that makes it stick.
Notice the design seam: Product knows nothing about Inventory, and Inventory only talks to products through their public attributes. Either can change internally without breaking the other. That separation, far more than any syntax, is what object-oriented design actually is, and you just used it.
! Common mistakes to avoid
-
✕Forgetting self in the method signature, then getting "takes 1 positional argument but 2 were given".
✓Every instance method receives the object first. That error message almost always means a missing self in the def line.
-
✕Assigning to a local name inside a method instead of the attribute: balance = 100 instead of self.balance = 100.
✓Without self. you create a local variable that vanishes when the method ends. Attribute state always goes through self.
-
✕Using a mutable class attribute (like a list defined on the class) as per-object storage.
✓Class-level lists are shared by all instances, the Part 4 default-argument trap in new clothing. Create per-object state inside __init__, or use field(default_factory=list) in dataclasses.
-
✕Writing getter and setter methods for everything, Java style.
✓Pythonic code accesses attributes directly. If logic must run on access later, the @property decorator adds it without changing callers; you do not need to pre-build the ceremony.
? Frequently asked questions
When should I write a class instead of functions and dicts? +
When data and behavior genuinely belong together, when you need many instances with identical behavior, or when state must be protected by rules, like the balance check. A script of three functions does not need a class; a thing with invariants does.
What does __init__ actually return? +
Nothing; it must return None. It does not create the object, it initializes one that Python has already created. The class call itself returns the finished object.
Are attributes private in Python? +
Not enforced. The convention is a leading underscore for "internal, do not touch": self._balance. Python trusts adults; the underscore is a contract with readers rather than a lock.
Dataclass or regular class? +
Dataclass when the essence is named fields, regular class when the essence is behavior with some internal state. The Product/Inventory split in the playground is the typical division of labor.
6. Recap and what comes next
You can now read and write the dominant idiom of the Python ecosystem: classes with __init__ and self, methods and attributes, string dunders, inheritance with super() applied through the is-a test, composition as the default, and dataclasses for clean records. From here on, library documentation will read differently, because you now speak its native grammar.
Next, the course makes your code survive contact with reality: Part 7, exceptions and file handling covers errors as a control flow you design for, and reading and writing real files safely. The Object and Class lesson in the Learn Python app below has flashcard-style reviews of today's terms, and the full sixteen-part syllabus is on the series hub.
Pro tip
When a class confuses you, instantiate it in the REPL and inspect it: vars(obj) shows its attributes as a plain dictionary, and type(obj).__mro__ shows the inheritance chain. Objects stop being abstract the moment you can see their insides.
Practice on the go
Learn Python, the free Android app
Every topic in this series lives in the app too: bite-size lessons, runnable examples, quizzes, mini projects, and an offline Python playground that runs on your phone.
Comments
0No comments yet. Be the first to share your thoughts.