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.
- 1What is object-oriented programming
- 2Classes and objects
- 3The constructor: __init__
- 4Attributes and methods
- 5Encapsulation and private attributes
- 6Inheritance
- 7Method overriding and super()
- 8Polymorphism
- 9Class attributes vs instance attributes
- 10Dunder methods and @property
- 11Real-world example: a banking app
- 12Common mistakes and how to fix them
- 13Terminology cheat sheet
- 14Where to go next
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
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.
class Dog: def bark(self): print("Woof!") my_dog = Dog() # Creating an object my_dog.bark() # Calling a method
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.
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.
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()
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 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.
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.
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
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.
| Convention | Meaning |
|---|---|
self.name | Public. Freely accessible and modifiable from outside the class. |
self._name | Protected by convention only. Signals "internal use", but nothing stops outside access. |
self.__name | Private by name mangling. Python renames it internally to make accidental outside access harder. |
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.
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()
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.
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.
class Animal: def speak(self): print("Animal speaks") class Cat(Animal): def speak(self): print("Meow") c = Cat() c.speak() # Meow
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:
class Cat(Animal): def speak(self): super().speak() # calls Animal's version first print("Meow") c = Cat() c.speak()
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.
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
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.
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.
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
__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.
| Method | Called 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 |
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
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.
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
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.
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
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
| Mistake | What happens | Fix |
|---|---|---|
Forgetting self as the first parameter | TypeError: method takes 0 positional arguments but 1 was given | Every 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 argument | Call methods on an instance, not the class itself, unless you deliberately intend a static or class method |
Trying to access obj.__balance from outside | AttributeError | Use 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 own | Default to None and create a new list inside the method: if items is None: items = [] |
| Confusing class attributes with instance attributes | Changing a value through one object appears to affect every other object | Set anything that should be unique per object inside __init__() with self. |
13Terminology cheat sheet
| Concept | Description |
|---|---|
| Class | Blueprint for creating objects |
| Object | An instance of a class |
| Method | A function defined inside a class |
| Attribute | A variable tied to a class or an instance |
| Encapsulation | Restricting direct access to internal data |
| Inheritance | Creating a class based on another, reusing its behavior |
| Polymorphism | Using the same method name across different classes with different behavior |
| Dunder method | A 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
selfas 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, orBook, 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@propertyfor 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.
