Python · Fundamentals

Object-Oriented Programming in Python: The Complete Beginner's Guide

Classes, objects, encapsulation, inheritance, and polymorphism explained from first principles, with working code, real output, and the reasoning behind every concept.

By Arj Updated July 2026 24 min read Python 3

1What is object-oriented programming

Python is a versatile language that supports both procedural and object-oriented styles, and it does not force you to pick one exclusively. Object-oriented programming (OOP) is a way of structuring code by bundling related data and behavior into reusable blueprints called classes, and the actual, usable copies made from those blueprints are called objects.

The core idea is modeling real-world entities in code. A Car has properties (color, speed, fuel level) and behaviors (accelerate, brake, refuel). A Student has a name and a grade level, and can enroll in a course. OOP gives you a natural way to represent that structure directly in your program instead of scattering related data and functions across the file separately.

Why OOP matters

  • Organizes code into logical components, each class owns its own data and the functions that act on it
  • Makes large codebases easier to maintain, since related logic lives in one place instead of being spread across many functions
  • Encourages reusability through inheritance, letting new classes build on existing ones instead of starting from scratch
  • Helps avoid code duplication, because shared behavior is written once in a parent class
Where this fits in your learning path
This guide assumes comfort with Python functions, variables, and basic control flow. If any of that feels shaky, the Python tutorial is the right place to start before tackling classes.

2Classes and objects

A class is a blueprint. It describes what data an object will hold and what it can do, but it is not itself a usable thing yet. An object is a real instance created from that blueprint, with its own actual data.

python
class Dog:
    def bark(self):
        print("Woof!")

my_dog = Dog()    # Creating an object
my_dog.bark()     # Calling a method
OutputWoof!

The class defines what a Dog can do (bark). my_dog is one specific dog object, created by calling the class like a function. You could create a hundred separate dog objects from the same class, each independent of the others.

The blueprint analogy
Think of a class as an architectural blueprint for a house, and each object as an actual house built from it. The blueprint itself is not livable, but you can build as many houses from it as you like, each standing on its own.

3The constructor: __init__

The bare Dog class above cannot hold any data of its own, every dog just barks the same generic way. The __init__() method fixes that. It runs automatically the moment a new object is created, and its job is to set up that object's starting attributes.

python
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        print(f"{self.name} says woof!")

dog1 = Dog("Buddy", 3)
dog1.bark()
OutputBuddy says woof!

Every argument you pass when creating the object, "Buddy" and 3 here, flows straight into __init__(), which stores them on self so every other method in the class can reach them afterward.

self is not optional
self refers to the specific object a method is being called on, and Python passes it automatically as the first argument to every instance method. You still have to write it explicitly in the method definition, forgetting it is one of the most common early OOP mistakes and produces a confusing TypeError about argument counts.

4Attributes and methods

Once an object exists, its attributes (the variables tied to it, like self.name) and its methods (the functions tied to it, like bark()) are both accessible using dot notation.

python
print(dog1.name)     # Buddy
dog1.bark()          # Buddy says woof!

The distinction is simple once it clicks: attributes are nouns, the data describing the object, and methods are verbs, the actions the object can perform, often using its own attributes along the way.

5Encapsulation and private attributes

Encapsulation is the bundling of data and the methods that operate on it into one unit, the class, while also controlling how that data can be accessed from outside. This protects an object's internal state from being changed in ways that break its own rules.

Python does not have true enforced privacy the way some other languages do, but it uses a strong naming convention. A double underscore prefix, like self.__balance, signals that an attribute is private and triggers name mangling, which makes it awkward (though not impossible) to access from outside the class.

python
class Account:
    def __init__(self, balance):
        self.__balance = balance  # private variable

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

    def get_balance(self):
        return self.__balance

acc = Account(1000)
acc.deposit(500)
print(acc.get_balance())  # 1500
Output1500

You cannot access __balance directly from outside the class as acc.__balance, that raises an AttributeError. Instead, the class exposes controlled access through get_balance(), which means the class itself decides how its data can be read or changed, rather than leaving it wide open.

ConventionMeaning
self.namePublic. Freely accessible and modifiable from outside the class.
self._nameProtected by convention only. Signals "internal use", but nothing stops outside access.
self.__namePrivate by name mangling. Python renames it internally to make accidental outside access harder.
Why bother if it is not truly private
Even though Python will not stop a determined outsider, the convention communicates intent clearly to every other developer reading the code, and prevents accidental misuse far more often than deliberate ones. That is usually enough in practice.

6Inheritance

Inheritance lets one class, the child, reuse the attributes and methods of another class, the parent, without rewriting them. This is the primary mechanism OOP uses to avoid duplicating shared behavior across similar classes.

python
class Animal:
    def speak(self):
        print("Animal speaks")

class Dog(Animal):
    def bark(self):
        print("Dog barks")

d = Dog()
d.speak()  # Inherited from Animal
d.bark()
OutputAnimal speaks Dog barks

Dog(Animal) in the class definition means "Dog inherits from Animal". Every method and attribute defined on Animal becomes automatically available on any Dog object, on top of whatever Dog defines for itself.

The "is-a" test
A good rule for whether inheritance is the right tool: a Dog genuinely is an Animal. If the relationship between two classes cannot be phrased that way naturally, inheritance is probably the wrong fit, and composition (one class holding an instance of another) is usually a better model.

7Method overriding and super()

A child class can redefine a method it inherited to give it different behavior, this is called overriding. Python always uses the most specific version of a method available on an object, so the child's version takes priority over the parent's.

python
class Animal:
    def speak(self):
        print("Animal speaks")

class Cat(Animal):
    def speak(self):
        print("Meow")

c = Cat()
c.speak()  # Meow
OutputMeow

Sometimes you want to extend the parent's behavior rather than fully replace it. The built-in super() function lets a child method call the parent's version of itself before or after adding its own logic:

python
class Cat(Animal):
    def speak(self):
        super().speak()  # calls Animal's version first
        print("Meow")

c = Cat()
c.speak()
OutputAnimal speaks Meow

super() is especially common inside __init__(), where a child class wants the parent's setup logic to run first, then add its own additional attributes on top.

8Polymorphism

Polymorphism means different classes can implement the same method name in their own way, and code that calls that method does not need to know or care which specific class it is dealing with, it just calls the method and gets the right behavior back.

python
class Bird:
    def sound(self):
        print("Chirp")

class Duck:
    def sound(self):
        print("Quack")

def make_sound(animal):
    animal.sound()

b = Bird()
d = Duck()

make_sound(b)  # Chirp
make_sound(d)  # Quack
OutputChirp Quack

Notice that Bird and Duck here are not even related through inheritance, they just both happen to define a sound() method. make_sound() does not check what type its argument is, it simply trusts that whatever is passed in has a sound() method and calls it. This flexible, trust-based style is often called duck typing in Python specifically because of examples exactly like this one.

Why this is powerful
Polymorphism means you can write one function that works with any object exposing the right interface, rather than writing a separate version for every possible type. This is the foundation of how flexible, extensible systems are built in Python.

9Class attributes vs instance attributes

Everything so far has used instance attributes, values set with self.x = ... inside __init__(), unique to each object. Python also supports class attributes, defined directly inside the class body, which are shared across every instance of that class.

python
class Dog:
    species = "Canis familiaris"  # class attribute, shared by all dogs

    def __init__(self, name):
        self.name = name  # instance attribute, unique per dog

d1 = Dog("Buddy")
d2 = Dog("Max")

print(d1.species, d2.species)  # same value for both
print(d1.name, d2.name)        # different for each
OutputCanis familiaris Canis familiaris Buddy Max
A common trap with mutable class attributes
If a class attribute is a mutable object like a list, and you modify it through one instance without reassigning it, that change is visible to every other instance too, since they all share the exact same underlying object. For anything that should be unique per object, set it inside __init__() as an instance attribute instead.

10Dunder methods and @property

__init__ is one example of a dunder method (short for "double underscore"), special methods Python calls automatically in specific situations. A few others come up constantly once your classes get more realistic.

MethodCalled when
__init__(self, ...)An object is created
__str__(self)print(obj) or str(obj) is used, controls the readable display form
__repr__(self)The object is shown in a debugger or the interactive shell, meant to be unambiguous
__eq__(self, other)obj1 == obj2 is evaluated
__len__(self)len(obj) is called
python
class Dog:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"Dog named {self.name}"

d = Dog("Buddy")
print(d)  # uses __str__ automatically
OutputDog named Buddy

The built-in @property decorator lets a method be accessed like a plain attribute, without parentheses, which is useful for computed values or for adding validation when a value is read or set.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

c = Circle(4)
print(c.area)  # no parentheses needed, reads like an attribute
Output50.26544

11Real-world example: a banking app

Bringing several of these ideas together, here is a small but realistic BankAccount class using a constructor, encapsulation, and methods that enforce a business rule.

python
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")
        else:
            self.__balance -= amount

    def get_balance(self):
        return self.__balance

account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance())  # 1300
Output1300

Notice how the encapsulation from section 5 is doing real work here: the withdraw() method enforces a rule (you cannot overdraw the account) that would be trivially bypassed if __balance were public and any part of the program could change it directly. This is the practical payoff of bundling data with the logic that is allowed to touch it.

12Common mistakes and how to fix them

MistakeWhat happensFix
Forgetting self as the first parameterTypeError: method takes 0 positional arguments but 1 was givenEvery instance method needs self listed first, even if the method takes no other arguments
Calling Dog.bark() instead of my_dog.bark()TypeError about a missing self argumentCall methods on an instance, not the class itself, unless you deliberately intend a static or class method
Trying to access obj.__balance from outsideAttributeErrorUse the class's own public method, like get_balance(), to read private data
Using a mutable default argument, like def __init__(self, items=[])The same list is shared across every object that does not pass its ownDefault to None and create a new list inside the method: if items is None: items = []
Confusing class attributes with instance attributesChanging a value through one object appears to affect every other objectSet anything that should be unique per object inside __init__() with self.

13Terminology cheat sheet

ConceptDescription
ClassBlueprint for creating objects
ObjectAn instance of a class
MethodA function defined inside a class
AttributeA variable tied to a class or an instance
EncapsulationRestricting direct access to internal data
InheritanceCreating a class based on another, reusing its behavior
PolymorphismUsing the same method name across different classes with different behavior
Dunder methodA special method like __init__ or __str__ that Python calls automatically

14Where to go next

A short set of habits carries most of the value in this guide forward into your own code:

  • Always use self as the first parameter in instance methods.
  • Use __init__() to set up every attribute an object needs, rather than adding them later in scattered places.
  • Practice with real-world entities like Car, Student, or Book, concrete examples make the concepts stick faster than abstract ones.
  • Apply OOP gradually. A short script that runs once does not need a class hierarchy, force it only where the structure actually earns its complexity.
  • Explore further: multiple inheritance, super() in deeper hierarchies, and @property for computed or validated attributes.

Once classes and objects feel natural, a good next step is applying them in something with real structure:

  • Build a small project, like a to-do app or a simple student management system, using two or three classes that interact.
  • Explore design patterns, such as Singleton or Factory, which are just well-known, reusable arrangements of classes solving common structural problems.
  • Learn OOP with a GUI using tkinter or PyQt, where windows and widgets are themselves objects you configure and extend.
  • Apply OOP in web frameworks like Flask and Django, both of which are built almost entirely around class-based structures once you look past the surface.

For file-based state in an OOP project, like saving and loading account data between runs, the file handling in Python guide pairs naturally with what you just learned. For the full sequence of what to study before and after OOP, see the Python roadmap.

Keep building your Python skill tree
See exactly what to learn after OOP, in order, with every guide on this site mapped to a stage.
View the Python roadmap

Stop wrestling with confusion.

Join thousands of students mastering Computer Science without the academic jargon.

From syntax to systems. We break down the hardest ideas in computer science so you can actually build things.

© 2026 Painless Programming. Built for students.
Scroll to Top