☰ Learn Python Tutorial Menu

Inheritance and Dunder Methods

Written by CSA mentors · Updated 26 Sept 2026 · 5 min read


Give this lesson an hour and your classes will behave like built-in types. They'll print nicely, compare with ==, sort with sorted(), add up with sum(). Two ideas get you there. Inheritance lets a child class reuse a parent's code and change only what differs, so a savings account and a current account can both be accounts without copying code. Dunder methods (double-underscore methods like __str__) are how Python connects operators and built-ins to your objects.

Inheritance basics

An Account parent class with SavingsAccount and CurrentAccount children that add or override behaviour
class Account:
    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:
            raise ValueError("insufficient funds")
        self.balance -= amount

    def __str__(self):
        return f"{type(self).__name__} of {self.owner}: Rs {self.balance:,}"


class SavingsAccount(Account):
    def __init__(self, owner, balance=0, rate=0.08):
        super().__init__(owner, balance)     # let the parent set owner and balance
        self.rate = rate

    def add_profit(self):
        self.balance += round(self.balance * self.rate / 12)


class CurrentAccount(Account):
    def __init__(self, owner, balance=0, overdraft_limit=50000):
        super().__init__(owner, balance)
        self.overdraft_limit = overdraft_limit

    def withdraw(self, amount):                  # override the parent's version
        if amount > self.balance + self.overdraft_limit:
            raise ValueError("over overdraft limit")
        self.balance -= amount


s = SavingsAccount("Sana", 120000)
s.add_profit()
c = CurrentAccount("Zara Textiles", 10000)
c.withdraw(40000)                                # allowed: goes to -30000
print(s)                                         # SavingsAccount of Sana: Rs 120,800
print(c)                                         # CurrentAccount of Zara Textiles: Rs -30,000

What to notice in there.

  • class SavingsAccount(Account): names the parent in parentheses. The child gets every parent method for free, so s.deposit() works without being written again.
  • super().__init__(...) calls the parent's constructor so shared setup lives in one place. Forgetting this line is the most common inheritance bug we see, and the symptom is an AttributeError about owner or balance.
  • Defining a method with the same name in the child overrides the parent's. CurrentAccount.withdraw replaces Account.withdraw, while SavingsAccount still uses the original.
  • type(self).__name__ in the parent's __str__ prints the actual class name, so one __str__ serves all the children.

isinstance and polymorphism

accounts = [s, c, Account("Cash", 5000)]
for a in accounts:
    a.deposit(1000)                              # same call, each does the right thing
    print(a, "| savings?", isinstance(a, SavingsAccount))

Calling the same method on different types and having each respond in its own way is polymorphism. A report function can accept any account without caring which kind it is. isinstance(obj, Class) is true for the class and all its children. Use it rarely. If you find yourself checking types inside a loop, the design usually wants another method on the parent instead.

Dunder methods

Python maps operators and built-ins to specially named methods. Implement them and your class plugs straight into the language.

You writePython callsTypical use
print(x), str(x)x.__str__()Human-readable text
x in a list, debuggerx.__repr__()Developer text, ideally valid code
x == yx.__eq__(y)Compare by value, not identity
x < y, sorted()x.__lt__(y)Ordering
x + yx.__add__(y)Combining, e.g. Money
len(x)x.__len__()Containers
x[key]x.__getitem__(key)Indexing
for item in xx.__iter__()Looping
with x:__enter__, __exit__Cleanup, like files

A Money class

class Money:
    def __init__(self, paisa):
        self.paisa = int(paisa)                  # store whole paisa: no float errors

    @classmethod
    def rupees(cls, amount):
        return cls(round(amount * 100))

    def __repr__(self):
        return f"Money.rupees({self.paisa / 100:.2f})"

    def __str__(self):
        return f"Rs {self.paisa / 100:,.2f}"

    def __add__(self, other):
        return Money(self.paisa + other.paisa)

    def __eq__(self, other):
        return isinstance(other, Money) and self.paisa == other.paisa

    def __lt__(self, other):
        return self.paisa < other.paisa


a = Money.rupees(1250.75)
b = Money.rupees(0.25)
print(a + b)                                     # Rs 1,251.00
print(a == Money.rupees(1250.75))                # True
print(sorted([a, b, Money.rupees(99)]))          # [Money.rupees(0.25), Money.rupees(99.00), Money.rupees(1250.75)]
print(sum([a, b], Money(0)))                     # Rs 1,251.00

Storing whole paisa as an integer is a deliberate choice, and it's the one we recommend for any money class. It sidesteps the float problems from Lesson 3 entirely. @classmethod receives the class instead of an instance and is the standard way to offer alternative constructors like Money.rupees(). __repr__ is what you see inside lists and error messages, so make it unambiguous. Once __lt__ exists, sorted and min just work.

A container class

class Ledger:
    def __init__(self):
        self._entries = []

    def add(self, description, amount):
        self._entries.append((description, amount))

    def __len__(self):
        return len(self._entries)

    def __iter__(self):
        return iter(self._entries)

    def __getitem__(self, index):
        return self._entries[index]

led = Ledger()
led.add("Sales", 45000)
led.add("Rent", -20000)
print(len(led), led[0])
for desc, amt in led:
    print(desc, amt)

The leading underscore in _entries is a convention meaning "internal, don't touch from outside". Python doesn't enforce it, but every Python programmer respects it, and reviewers will comment if you reach past it.

Composition over inheritance

Inheritance models "is a". A savings account is an account. When the relationship is "has a", give the class an attribute instead. An Invoice has a Customer, it's not a kind of customer. Deep inheritance trees turn brittle fast, and one or two levels is plenty for analytics code.

Tariffs as classes at K-Electric: A billing prototype has a base Tariff with bill(units), and children ResidentialTariff, CommercialTariff and AgriculturalTariff that override only the slab logic. The monthly run loops over hundreds of thousands of customers calling customer.tariff.bill(units) without a single if on category. When a new "lifeline" tariff came in, it was a 15-line child class and no other file changed.

Two ideas to keep

First, class Child(Parent): inherits everything, super().__init__() reuses the parent's setup, and redefining a method in the child overrides it while the parent's other methods stay. Second, dunder methods connect your class to print, ==, <, +, len and loops, so always define __repr__, add __eq__ and __lt__ when objects need comparing or sorting, and prefer composition ("has a") over deep inheritance ("is a").

Three to try

  1. Extend Account with a FixedDeposit class whose withdraw raises an error before a maturity_date and works normally after it.
  2. Add __sub__ and __mul__ (multiply by a number) to Money, and make Money.rupees(100) * 3 == Money.rupees(300) print True.
  3. Give Ledger a balance() method and a __str__ that lists every entry and the balance, then write a MonthlyLedger(Ledger) child that also stores a month label.

Lesson 19 of 26

Sign in to track your progress and earn learning points for every lesson you finish.

Example

print("Hello from CSA!")

for i in range(5):
    print(i * i)
Try it Yourself »