Python OOP Explained: Classes, __init__, self and Inheritance

Diagram of a Python Character class as a blueprint creating hero and villain objects, with a Mage subclass below

Python OOP (object-oriented programming) is a way of organising code by bundling related data and the functions that change that data into objects, which are built from classes. Four ideas carry most of the weight: the class itself, the __init__ method that sets up each new object, the self parameter that lets methods act on their own object, and inheritance, which lets one class extend another.

This guide walks through those ideas using a small game as the running example. It covers why classes exist, what happens inside when you create an object or call a method, where you already meet classes in libraries like Django and pandas, and the three mistakes that catch out almost everyone. It ends with a practical test for deciding when a class is actually the right tool.

In short
  • A class is a blueprint; each object built from it holds its own data.
  • __init__ runs automatically and gives every new object its starting attributes.
  • self is whichever object you called the method on.
  • Inheritance with super() reuses a parent class and adds only what is new.
  • Avoid mutable default arguments, and prefer composition over deep inheritance chains.
  1. 0:00Intro
  2. 0:27When a script outgrows itself
  3. 1:26Objects bundle data and behaviour
  4. 2:16Three reasons classes exist
  5. 2:54Define a class, then __init__
  6. 3:55Methods use self to act
  7. 4:50Extend with inheritance and super()
  8. 5:44Classes are everywhere in Python
  9. 6:34A Django model, walked through
  10. 7:35Three mistakes that bite everyone
  11. 8:34Has-a often beats is-a
  12. 9:15When a class is the right tool
  13. 9:59Python OOP in six lines
  14. 10:23Small classes: data and behaviour together

Why does a growing Python script need classes?

Picture a small game script that has slowly grown to a few hundred lines. Player data lives in dictionaries, and the functions that change it, such as a hit(p, dmg) function, are spread across the file. Nothing is broken yet, but every new line makes the code harder to reason about. You open the file and wonder which function actually changes a player's health.

Three problems tend to appear together. Data is scattered, and because each player is a loose dictionary, nothing enforces which keys must exist. Logic is copied, so every new enemy type gets its own slightly different version of the same functions. And changes become risky, because renaming one key can break code several screens away.

  • Scattered data: a typo like 'helth' quietly creates a new key instead of failing.
  • Copied logic: fix a bug in one copy and it usually survives in the others.
  • Risky changes: nothing ties the data to the code that depends on it.

What is object-oriented programming in Python?

Object-oriented programming means bundling related data, and the functions that act on that data, into objects. Those objects are built from classes. A useful picture is a blueprint: the class describes what something looks like and what it can do, and each object is one thing built from that blueprint.

Say you define a class called Character. Calling it once builds an object called hero with its own name. Calling it again builds villain. That is one definition and two independent objects, each holding its own data. Changing hero never touches villain, because every object keeps its own copy of its values.

Two terms describe what lives inside an object. Attributes are the data, such as a name and a health number. Methods are the behaviour: functions written inside the class, so the code that changes health sits right next to the health itself. That pairing of data with the code that changes it is the whole idea.

  • Attributes = data stored on each object.
  • Methods = functions inside the class that act on that data.

Why do classes exist? Three practical reasons

Classes are not just a style preference. They exist for three practical reasons, and each one answers a problem from the growing script above. First, they let you model real things: a Character class reads like the game it describes. Second, they reduce copying, because behaviour is written once and every object shares it.

Third, they make upkeep easier, since changes happen in one place. This is the real payoff. If you fix a bug inside a method, every object built from that class picks up the fix immediately. There are no copies scattered across the file to hunt down, and no risk that one forgotten version keeps the old bug alive.

How does __init__ work in a Python class?

Defining a class is step one. Step two is usually a special method called __init__ (often read aloud as 'dunder init'), which sets up each new object's attributes the moment it is created. In the example, __init__ takes self, a name, and a health value with a default. The two self lines store those values on the object, and printing hero.health shows 100.

You never call __init__ yourself. When you write Character("Ada"), Python creates the object and then calls __init__ on it, passing along the arguments you gave. Assigning to self.name is what creates an instance attribute. Without the self. prefix, name would just be a local variable that disappears when __init__ returns.

One detail is worth knowing: __init__ is an initialiser, not a creator. Strictly speaking, a separate method, __new__, builds the object, and __init__ fills it in. In everyday code you rarely touch __new__, but the distinction explains why __init__ receives an object that already exists.

  • Runs automatically right after the object is created.
  • self.x = value stores data on that one object.
  • __new__ builds the object; __init__ fills it in.
class Character:
    def __init__(self, name, health=100):
        self.name = name
        self.health = health
hero = Character("Ada")
print(hero.health)  # 100

What does self mean in Python methods?

Once each object has its own data, it needs behaviour, and methods provide it. The trick behind self is simple: when you call a method on an object, Python quietly passes that object in as the first argument. So hero.take_damage(30) is really Character.take_damage(hero, 30).

Step by step, Python finds take_damage on the Character class, binds self to hero, and runs the method. Health goes from 100 to 70, so hero.health is now 70. Whichever object sits before the dot becomes self inside the method.

That is why calling villain.take_damage runs the very same code with a different self, and hero's health never changes. You get one method, many objects, and no shared mess. The name self is only a convention, not a keyword, but every Python developer expects it, so keep using it.

How do inheritance and super() work in Python?

Inheritance lets a new class extend an existing one and reuse everything it has without rewriting it. Writing class Mage(Character) means Mage inherits from Character. In the example, Mage's __init__ takes a name and mana, calls super().__init__(name) to hand the name to Character's own setup, then adds self.mana. A cast method spends some of that mana.

It helps to picture a class tree. Character sits at the root with name and health. A Warrior subclass might add rage, and Mage adds mana. Methods flow down the tree, so both subclasses get take_damage for free and only add what is genuinely new.

The call to super() matters. If you forget it, Character's __init__ never runs, so the mage has no name or health. You will see an AttributeError the first time a method reaches for one of those attributes.

class Mage(Character):
    def __init__(self, name, mana):
        super().__init__(name)
        self.mana = mana
    def cast(self):
        self.mana -= 10

Where are classes used in real Python code?

Classes are everywhere in Python. Django models turn database rows into objects, a pandas DataFrame is a class whose methods filter and group your table, and most libraries ship their tools as classes. When you write df.head(), you are calling a method on a DataFrame instance, and inside that method, self is your df.

A Django model shows every idea at once. Article inherits from models.Model, title is a CharField that becomes a column, and __str__ returns self.title. Calling Article.objects.create saves a row and hands back an object. That single base class brings saving, deleting, querying and validation, none of which you write yourself.

Notice that title is a class attribute, not something set in __init__. Django reads these attributes to build the table, and its own __init__ fills in each object's values. Meanwhile __str__ uses self, so every article prints its own title in the admin and the shell.

from django.db import models
class Article(models.Model):
    title = models.CharField(max_length=200)
    def __str__(self):
        return self.title
post = Article.objects.create(title="Hi")

Common Python OOP mistakes and how to avoid them

The first mistake hides in an innocent-looking __init__. A Bag class defaults items to an empty list and stores it on self. Build two bags, a and b, append a sword to a.items, then print b.items: the sword is there too. Python evaluates a default value once, when the function is defined, so every Bag shares that single list. Default to None instead and create a fresh list inside __init__.

The second is forgetting self. Leave it out of a method's parameters and calling the method raises a TypeError about extra arguments. Leave it off an assignment and the value never reaches the object. The third is deep inheritance: long chains of subclasses make it hard to tell where a method really comes from, and a change at the top ripples through everything below.

  • Mutable defaults: use None, then create the list inside __init__.
  • Missing self: causes TypeErrors or attributes that silently vanish.
  • Deep inheritance: hides where behaviour actually lives.
class Bag:
    def __init__(self, items=[]):
        self.items = items
a, b = Bag(), Bag()
a.items.append("sword")
print(b.items)  # ['sword']

Composition vs inheritance: when is a class the right tool?

Composition is the usual cure for deep inheritance. Instead of a class being a kind of another class, it holds other objects as parts and hands work to them. Compare a FireMage that inherits Mage, which inherits Character, with a Character that simply has a Weapon and an Inventory. In the inheritance chain, a flying fire mage needs yet another subclass; with composition, you swap small, testable parts freely.

A good rule of thumb: inherit only when the subclass truly is a kind of the parent and could stand in for it anywhere. Otherwise, give the object a part to hold. To decide whether to write a class at all, ask whether this data and this behaviour belong together. Where state and the rules that change it overlap, a class fits.

Keep each class small, with one clear job. If its name needs the word 'and', or it collects dozens of methods, split it. And if there is no state to carry between calls, a class adds ceremony and nothing else; a plain function, or a dataclass for plain data, is often cleaner.

Key takeaways

  • Classes bundle data and the behaviour that changes it.
  • __init__ sets each object's starting attributes through self.
  • Methods take self so each object acts on its own data.
  • Inherit and call super() to extend a class without rewriting it.
  • Never use a mutable value like as a default argument.
  • Prefer composition over deep inheritance chains, and keep classes small.

Frequently asked questions

Do I have to call __init__ myself?

No. When you call the class, for example Character("Ada"), Python creates the object and then calls __init__ on it automatically with the arguments you passed.

Is self a keyword in Python?

No, self is only a naming convention for the first parameter of a method. Python passes the object you called the method on into that position, whatever it is named, but every Python developer expects the name self, so stick with it.

What happens if I forget super().__init__() in a subclass?

The parent's __init__ never runs, so any attributes it sets, such as name and health, never exist on the object. You will usually get an AttributeError the first time a method tries to use them.

Why do two objects share the same list when I use a default argument?

Python evaluates default values once, when the function is defined, not each time it is called. A default like items= is therefore one list shared by every object; default to None and create a new list inside __init__ instead.

Should I use inheritance or composition?

Use inheritance when the subclass truly is a kind of the parent and could stand in for it anywhere. For everything else, composition, where an object holds other objects as parts, is usually simpler to change and test.

When should I use a function instead of a class?

If there is no state to keep between calls, a plain function is usually enough and a class just adds ceremony. For bundles of plain data, a dataclass is often a cleaner option.

Watch the full video on YouTube →

Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

Post a Comment

CAN FEEDBACK
Ad