OOP Basics in Python: Classes, Objects and Inheritance with Examples
Build one Python BankAccount program step by step, then use it to understand object state, validation, inheritance and runtime method selection.
KnowledgeGate Team
Exam prep & CS education

You can write variables, loops and functions, yet class, self and object creation may still feel like extra syntax. One BankAccount lifecycle connects object state, input validation, subclass construction and runtime method selection. Trace what each line stores or calls instead of memorising four OOP labels. Refresh the language foundations first in Python Programming.
What OOP means in Python
A class is a reusable description of state and behaviour. An object is one concrete instance with its own state. In our model, BankAccount is the class, while current and savings are objects. Their owner and _balance values are attributes, and deposit() and withdraw() are methods.
We could keep balances in loose variables and pass them through free functions. A class becomes useful because the balance and the operations allowed to change it travel together. Two account objects can follow the same rules while keeping separate values.
Python supports object-oriented code, but it does not require every program to use classes. A short data transformation can remain a function. Consider a class when several values and behaviours belong to the same entity and that entity changes over time.
Build the first class and trace one object
Start with this base class and one account:
class BankAccount:
currency = "INR"
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient balance")
self._balance -= amount
def summary(self):
return f"{self.owner}: balance {self._balance}"
current = BankAccount("Asha", 1500)
current.deposit(500)
current.withdraw(300)
print(current.summary())Construction sets owner to "Asha" and _balance to 1500. The deposit changes the balance to 1500 + 500 = 2000. The withdrawal then changes it to 2000 - 300 = 1700. The exact printed line is Asha: balance 1700.
self means the object receiving a method call. In current.deposit(500), Python supplies current as self, while the caller supplies 500 as amount. The __init__ method is the initializer that gives each new object its starting attributes. The caller provides "Asha" and 1500; Python again provides current as self.
Encapsulation and abstraction in Python
The single underscore in _balance signals an internal implementation detail. It is a convention, not enforced privacy. The read-only balance property exposes the value, while deposit() and withdraw() control updates.
After the first program runs, calling current.withdraw(2000) raises ValueError("Insufficient balance"). Calling current.withdraw(0), current.withdraw(-100) or current.deposit(-100) raises ValueError("Amount must be positive"). Every check runs before mutation, so current.balance remains 1700.
Encapsulation groups the account state with the methods that protect it. Abstraction lets a caller request deposit(500) without calculating and assigning _balance manually. It gives the caller a focused interface, but it does not mean that every implementation detail is literally hidden.

Add inheritance and observe polymorphism
Run this next in the same file, after the first block:
class SavingsAccount(BankAccount):
def __init__(self, owner, balance, interest_rate):
super().__init__(owner, balance)
self.interest_rate = interest_rate
def add_interest(self):
self._balance += self._balance * self.interest_rate
def summary(self):
rate = self.interest_rate * 100
return f"{self.owner}: savings balance {self._balance} at {rate}%"
savings = SavingsAccount("Ravi", 2000, 0.05)
savings.add_interest()
for account in [current, savings]:
print(account.summary())The call to super().__init__("Ravi", 2000) creates the inherited owner and _balance state. Interest is 2000 * 0.05 = 100.0, so the new balance is 2000 + 100.0 = 2100.0. The loop prints Asha: balance 1700, followed by Ravi: savings balance 2100.0 at 5.0%.
Inheritance applies because SavingsAccount is a specialised BankAccount. Method overriding means the subclass supplies its own summary(). Polymorphism appears in the loop: the same account.summary() expression selects BankAccount.summary() for current and SavingsAccount.summary() for savings, based on the runtime object.

Instance state, class state and method lookup
The owner and _balance attributes belong separately to each object. Therefore, current.balance == 1700 and savings.balance == 2100.0. By contrast, BankAccount.currency = "INR" is a class attribute. Both objects initially find that shared value and report "INR".
Now assign current.currency = "USD". The three results are precise: current.currency is "USD", savings.currency is still "INR", and BankAccount.currency remains "INR". The assignment created an instance attribute on current; it did not edit the class attribute.
At a beginner level, lookup works like this: Python checks the object, then its class, then base classes. That is why current.currency finds its own value, SavingsAccount.summary() overrides the base method, and savings.deposit() still finds the inherited method.
Common OOP errors in Python and how to fix them
A missing self is a common first error. If the method signature is def deposit(amount):, the call current.deposit(500) passes both the object and 500, causing a positional-argument TypeError. Fix the signature to def deposit(self, amount):.
A subclass can also forget parent initialization. If SavingsAccount.__init__() omits super().__init__(owner, balance), inherited methods later cannot find _balance. Initialize the base state before adding subclass-only attributes.
Do not confuse class state with instance state. A class-level mutable value such as transactions = [] would be shared by every account. Put self.transactions = [] inside __init__ when each account needs its own history. Finally, direct assignment to _balance bypasses validation. Normal calling code should use deposit() and withdraw().
How interviews and assessments test these basics
Use the program to practise three concrete question patterns. First, trace the two summary() lines. Second, predict all three currency values after current.currency = "USD". Third, explain why SavingsAccount should inherit from BankAccount, while a separate Transaction would normally be composed into an account. The first is an is-a relationship; the second is a has-a relationship.
Asha and Ravi also appear as separate Student objects in Python Classes and Objects Tutorial, which owns constructor mechanics, self and instance-versus-class state. The account trace reuses those names deliberately but adds a protected balance, rejected state transitions and two runtime classes.
For five inheritance shapes, multiple inheritance and MRO, use Inheritance in Python: A Practical Tutorial. SavingsAccount needs only single inheritance: parent initialization establishes valid account state before subclass behaviour and dispatch.
Polymorphism and Dunder Methods in Python carries duck-typed area calls, Vector2D dunder methods, NotImplemented and equality. The account.summary() loop tests the narrower override-and-runtime-selection case.
The published OOP in Python: MRO, super() and Dunder Methods is the canonical interview-oriented map of MRO, super(), dunder methods, classmethod, staticmethod and property. The BankAccount trace follows state changes and validation across one parent-child pair.
Verify the account invariants, then extend the model
A class defines a model. Objects hold separate state. Methods operate on an object through self. Inheritance reuses and specialises behaviour. Polymorphism lets one call work across related object types.
Write three tests proving that withdraw(0), withdraw(-100) and withdraw(2000) each raise an error and leave current.balance at 1700. Then create StudentAccount(SavingsAccount) with a per-withdrawal limit of 500. Starting at 1200, it must reject withdraw(600), accept withdraw(400) and finish at 1200 - 400 = 800.
Use Python Programming for structured practice, or browse the wider Coding and Skill Development Courses category after every state trace and test above passes.
Keep learning

Itertools in Python: Lazy Iterators, Pipelines and Worked Examples
Learn how Python's itertools builds readable, lazy pipelines. Runnable examples cover infinite iterators, filtering, running totals, adjacent pairs, tee, combinations and groupby.

File Handling in Python: Read, Write, Append, CSV and Binary Examples
Learn a reliable file-handling workflow through one scores file, then apply it to cursor control, append mode, CSV quoting, binary records and common errors.

Decorators in Python: @ Syntax, Wrappers and Worked Examples
Understand when decoration happens, what a wrapper receives, and how return values travel back. Runnable examples cover parameters, stacking and built-ins.

Exceptions in Python: Handle Errors with try, except and finally
Learn how Python exceptions change control flow, how to catch expected failures without hiding bugs, and when to use else, finally, raise, and custom exceptions.

