Python Classes and Objects: OOP Tutorial With Real Examples
Learn object-oriented Python by building something real. Create classes, understand self and __init__, share behaviour with inheritance, protect data with properties, and know when a class is the wrong tool.
- A class is a blueprint, and an object is one thing built from it.
__init__sets up each new object, andselfrefers to that object.- Attributes set on the class are shared. Attributes set on
selfbelong to one object. - Use
@dataclassfor data-holding classes, and prefer simple functions when there is no state to keep.
As a script grows, you end up passing the same group of variables between functions, and it becomes hard to tell which data belongs with which logic. Python classes and objects solve this by bundling data and behaviour into one unit.
In this tutorial we build a small bank account step by step, and add each OOP idea only when it solves a problem. We ran every example on Python 3.13, and the output shown comes from that run. If you are new to the language, read our Python tutorial for beginners first.
What are classes and objects?
A class describes what something is and what it can do. An object, also called an instance, is a specific one created from that description. “Account” is a class, while Asha’s account and Ben’s account are two objects.
| Term | Meaning | Example |
|---|---|---|
| Class | The blueprint | Account |
| Object or instance | One thing made from it | a = Account("Asha") |
| Attribute | Data on the object | a.balance |
| Method | A function on the class | a.deposit(50) |
You have been using objects all along. In Python, strings, lists and even functions are objects, which is why you can write "hello".upper().
Your first class
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self.balance += amount
return self.balance
a = Account("Asha", 100)
b = Account("Ben")
a.deposit(50)
print(a.balance, b.balance)
# 150 0
__init__ runs automatically whenever you create an object, and it sets the starting state. Depositing into a did not touch b, because each object has its own data. Notice that the method also protects the balance from a bad value. Our guide to Python exception handling explains raise in more depth.
What is self?
self is simply the object that a method is working on. When you write a.deposit(50), Python effectively calls Account.deposit(a, 50), passing the object in as the first argument. That is how one method definition can serve every object.
The name self is a strong convention rather than a keyword, but always use it. Forgetting it in a method definition is the most common beginner error, and it produces a confusing message about the number of arguments.
Class attributes vs instance attributes
An attribute set directly in the class body is shared by all objects. One set on self belongs only to that object. This example shows how lookups behave:
class Account:
interest_rate = 0.04 # class attribute
def __init__(self, owner, balance=0):
self.owner = owner # instance attribute
self.balance = balance
a = Account("Asha", 100)
b = Account("Ben")
print(a.interest_rate, b.interest_rate) # 0.04 0.04
Account.interest_rate = 0.05
print(a.interest_rate, b.interest_rate) # 0.05 0.05
a.interest_rate = 0.09
print(a.interest_rate, b.interest_rate, Account.interest_rate)
# 0.09 0.05 0.05
That last line surprises people. Assigning through a did not change the shared value. It created a new instance attribute on a that hides the class one. Python looks on the object first, then on the class.
Special (dunder) methods
Methods with double underscores let your objects work with Python’s built-in features. Without them, printing an account shows a useless memory address. Adding two gives it a readable form:
def __str__(self):
return f"{self.owner}: {self.balance}"
def __repr__(self):
return f"Account({self.owner!r}, {self.balance})"
print(a) # Asha: 150
print([a, b]) # [Account('Asha', 150), Account('Ben', 0)]
| Method | Triggered by |
|---|---|
__init__ |
Creating an object |
__str__ |
print(obj), str(obj) |
__repr__ |
Debugging, or showing it in a list |
__eq__ |
a == b |
__len__ |
len(obj) |
__add__ |
a + b |
Inheritance
Inheritance lets a new class reuse an existing one and change only what differs. Here, Savings is an Account with an extra setting:
class Savings(Account):
def __init__(self, owner, balance=0, limit=3):
super().__init__(owner, balance)
self.limit = limit
def __str__(self):
return f"{super().__str__()} (savings)"
s = Savings("Chitra", 500)
s.deposit(100)
print(s, isinstance(s, Account), s.limit)
# Chitra: 600 (savings) True 3
super() calls the parent’s version, so the setup logic is not repeated. deposit was inherited as it was, and __str__ was overridden to add a label. Only use inheritance for a real “is a” relationship. If a class merely needs something, such as a car needing an engine, give it that object as an attribute. This is known as composition, and it is usually more flexible than deep inheritance chains.
Polymorphism
Polymorphism means different objects can respond to the same method call in their own way. Calling code does not need to know the exact type:
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return round(3.14159 * self.radius ** 2, 2)
for shape in [Square(3), Circle(2)]:
print(type(shape).__name__, shape.area())
# Square 9
# Circle 12.57
These two classes do not even share a parent. Python only cares that the object has an area method, an approach known as duck typing. Adding a Triangle later would need no change to the loop.
Encapsulation and properties
Python has no truly private attributes, and it relies on conventions instead. A single underscore, as in _price, tells other developers “this is internal, please do not touch”. To check values when they are set, use a property, which looks like a plain attribute from the outside:
class Product:
def __init__(self, price):
self.price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("price cannot be negative")
self._price = value
p = Product(100)
p.price = 150
print(p.price) # 150
p.price = -5 # ValueError: price cannot be negative
This means you can start with a plain attribute and add validation later without changing any code that uses the class. So do not write get_price() and set_price() methods out of habit, as you might in Java. The @ syntax here is a decorator, which we explain in Python decorators explained.
Class methods and static methods
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls(round((f - 32) * 5 / 9, 1))
@staticmethod
def is_freezing(celsius):
return celsius <= 0
Temperature.from_fahrenheit(212).celsius # 100.0
Temperature.is_freezing(-3) # True
| Type | First argument | Use it for |
|---|---|---|
| Instance method | self |
Working with one object’s data |
@classmethod |
cls |
Alternative constructors |
@staticmethod |
None | Helpers related to the class |
Dataclasses
When a class mostly holds data, writing __init__, __repr__ and __eq__ by hand is tedious. @dataclass generates them from type annotations:
from dataclasses import dataclass, field
@dataclass
class Item:
name: str
price: int
tags: list = field(default_factory=list)
print(Item("Pen", 10))
print(Item("Pen", 10) == Item("Pen", 10))
# Item(name='Pen', price=10, tags=[])
# True
Tip: Not everything needs a class. If it has no state and only one behaviour, a plain function is simpler. For simple lookups, a dictionary may be enough. Reach for a class when data and the rules that protect it clearly belong together.
Common mistakes
Using a mutable class attribute
This is a classic bug. The list is defined on the class, so all carts share it:
class Cart:
items = [] # shared by every cart!
def add(self, item):
self.items.append(item)
c1, c2 = Cart(), Cart()
c1.add("pen")
print(c2.items)
# ['pen']
Create it in __init__ with self.items = [], so that every object gets its own list.
Other common errors
- Forgetting
self. Writeself.balance, notbalance, inside methods. - Forgetting
super().__init__(). The child then misses attributes set up by the parent. - Building huge classes. A class that does everything is hard to test. Split it up by responsibility.
- Deep inheritance. Two levels are usually plenty, and composition is often clearer.
- Not writing tests. Classes with rules are easy to test, as our pytest tutorial shows using a small cart.
Final thoughts
Object-oriented Python comes down to grouping data with the behaviour that protects it. Start with __init__, methods and __str__, then add inheritance and properties when you hit a problem they solve. As a next step, take a script that passes the same few variables everywhere and turn them into one small class.
Frequently asked questions
What is the difference between a class and an object?
A class is the definition, or blueprint. An object is a specific instance created from it, with its own data. One class can create many objects.
What does __init__ do in Python?
It is the initializer, which runs automatically when you create an object. It usually sets the object’s starting attributes from the arguments you pass.
What is self in Python?
self refers to the object a method is called on. Python passes it automatically, and it lets the method read and change that object’s attributes.
What are the four pillars of OOP?
They are encapsulation, abstraction, inheritance and polymorphism. Together, they help you bundle data with behaviour, hide details and reuse code.
When should I use a class instead of a function?
Use a class when data and behaviour belong together across several operations. For one stateless operation, a plain function is simpler and easier to test.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.