Python Inheritance Explained — How, Why, and When to Use It
Every non-trivial Python application you'll ever work on — from a Django web app to a data pipeline — will involve objects that share behaviour. Maybe you're building a payment system with CreditCardPayment and PayPalPayment classes. Maybe you're modelling a vehicle fleet with Cars, Trucks, and Motorcycles. Without inheritance, you'd copy the same methods into every class, and the moment a requirement changes, you'd hunt down every copy to fix it. That's a maintenance nightmare waiting to happen.
Inheritance solves the 'copy-paste class' problem by letting one class absorb the attributes and methods of another. The parent class (also called a base or superclass) holds the shared logic. Child classes (subclasses) inherit that logic automatically and then extend or override it where they need something different. This keeps your codebase DRY — Don't Repeat Yourself — and makes adding new types trivially easy.
By the end of this article you'll understand not just the syntax of single, multilevel, and multiple inheritance, but — more importantly — you'll know when to reach for each one, what super() actually does under the hood, how Python resolves method conflicts via the MRO (Method Resolution Order), and the two most common mistakes that trip up even experienced developers. You'll also leave with concrete answers to the interview questions that catch people out.
Single Inheritance — The Foundation You Need to Nail First
Single inheritance is the simplest form: one child class inherits from exactly one parent class. This is the 90% case you'll encounter in real projects.
Here's the key mental model: the child class IS-A version of the parent. A SavingsAccount IS-A BankAccount. A ElectricCar IS-A Car. If you can't truthfully say 'X is a Y', inheritance probably isn't the right tool — you might want composition instead.
When a child class inherits from a parent, it gets every method and attribute the parent defines. It can use them as-is, override them to change their behaviour, or call them via super() and then extend the result. The super() function is your link back to the parent — it lets the child say 'do everything you normally do, and then I'll add my part on top'. Never hardcode the parent class name inside the child; always use super(). If you rename the parent class later, hardcoding it will silently break your code.
# Real-world example: a generic BankAccount and a specialised SavingsAccount class BankAccount: """The parent class — holds logic every bank account needs.""" def __init__(self, owner: str, balance: float = 0.0): self.owner = owner self.balance = balance # shared attribute every account type needs def deposit(self, amount: float) -> None: """Add funds. Validation lives here once, not in every subclass.""" if amount <= 0: raise ValueError("Deposit amount must be positive.") self.balance += amount print(f"Deposited £{amount:.2f}. New balance: £{self.balance:.2f}") def withdraw(self, amount: float) -> None: """Base withdrawal — no special rules yet.""" if amount > self.balance: raise ValueError("Insufficient funds.") self.balance -= amount print(f"Withdrew £{amount:.2f}. Remaining: £{self.balance:.2f}") def __repr__(self) -> str: return f"BankAccount(owner='{self.owner}', balance=£{self.balance:.2f})" class SavingsAccount(BankAccount): # <-- inherits from BankAccount """Child class — adds an interest rate and enforces a minimum balance.""" MINIMUM_BALANCE = 100.0 # class-level rule specific to savings accounts def __init__(self, owner: str, balance: float = 0.0, interest_rate: float = 0.03): # super().__init__ calls BankAccount.__init__ so we don't duplicate that logic super().__init__(owner, balance) self.interest_rate = interest_rate # SavingsAccount-specific attribute def withdraw(self, amount: float) -> None: """Override parent's withdraw to enforce minimum balance rule.""" if (self.balance - amount) < self.MINIMUM_BALANCE: raise ValueError( f"Cannot go below minimum balance of £{self.MINIMUM_BALANCE:.2f}." ) # Delegate the actual withdrawal logic back to the parent — DRY! super().withdraw(amount) def apply_interest(self) -> None: """New method that only SavingsAccount has — not on the parent.""" interest_earned = self.balance * self.interest_rate self.balance += interest_earned print(f"Interest applied: £{interest_earned:.2f}. Balance: £{self.balance:.2f}") def __repr__(self) -> str: return ( f"SavingsAccount(owner='{self.owner}', " f"balance=£{self.balance:.2f}, rate={self.interest_rate:.1%})" ) # --- Usage --- account = SavingsAccount(owner="Alice", balance=500.0, interest_rate=0.05) print(account) account.deposit(200.0) # inherited from BankAccount — no code duplication account.apply_interest() # only available on SavingsAccount account.withdraw(100.0) # overridden version — checks minimum balance try: account.withdraw(560.0) # this should fail — would breach minimum balance except ValueError as error: print(f"Blocked: {error}") print(account)
Deposited £200.00. New balance: £700.00
Interest applied: £35.00. Balance: £735.00
Withdrew £100.00. Remaining: £635.00
Blocked: Cannot go below minimum balance of £100.00.
SavingsAccount(owner='Alice', balance=£635.00, rate=5.0%)
Multilevel and Multiple Inheritance — Power Features With Real Trade-offs
Multilevel inheritance is a chain: C inherits from B, which inherits from A. Think of it as a lineage — Grandparent → Parent → Child. This models specialisation naturally. A PremiumSavingsAccount is a kind of SavingsAccount, which is a kind of BankAccount.
Multiple inheritance is Python-specific and more controversial: a single class can inherit from more than one parent at once. This is powerful but requires caution. Python solves the 'Diamond Problem' — where two parent classes share a common grandparent — using the Method Resolution Order (MRO). Python calculates the MRO using the C3 Linearisation algorithm. You can inspect any class's MRO by calling ClassName.__mro__ or ClassName.mro(). When Python looks for a method, it walks this list left to right and uses the first match it finds.
A good real-world use for multiple inheritance is mixins — small, focused classes that add a single capability (like logging or serialisation) without representing a full 'type'. Mixins are designed to be mixed in, not instantiated on their own.
# Multilevel inheritance: Vehicle -> Car -> ElectricCar # Multiple inheritance via a Mixin: adding GPS capability cleanly class Vehicle: """Top of the chain — every vehicle has these basics.""" def __init__(self, make: str, model: str, year: int): self.make = make self.model = model self.year = year def start_engine(self) -> str: return f"{self.make} {self.model} engine started." def __repr__(self) -> str: return f"{self.year} {self.make} {self.model}" class Car(Vehicle): """Multilevel level 2 — a Car IS-A Vehicle with door-count logic.""" def __init__(self, make: str, model: str, year: int, num_doors: int = 4): super().__init__(make, model, year) # pass shared args up the chain self.num_doors = num_doors def honk(self) -> str: return "Beep beep!" class GpsNavigationMixin: """ A mixin — NOT a standalone class, no __init__ of its own. It adds GPS behaviour to any class that includes it. """ def get_current_location(self) -> str: # In a real app this would call a GPS API return "51.5074° N, 0.1278° W (London, UK)" def navigate_to(self, destination: str) -> str: return f"Navigating to '{destination}'... Turn right in 200m." class ElectricCar(GpsNavigationMixin, Car): """ Multilevel level 3 + Multiple inheritance. ElectricCar IS-A Car, and also HAS GPS navigation (via mixin). MRO: ElectricCar -> GpsNavigationMixin -> Car -> Vehicle -> object """ def __init__(self, make: str, model: str, year: int, battery_kwh: float): # super().__init__ here follows the MRO — it reaches Car correctly super().__init__(make, model, year) self.battery_kwh = battery_kwh self.charge_level = 100.0 # percentage def start_engine(self) -> str: # Override parent's method — electric cars don't have a combustion engine return f"{self.make} {self.model} motor silently activated. ⚡" def charge_status(self) -> str: return f"Battery: {self.charge_level:.0f}% ({self.battery_kwh} kWh capacity)" # --- Inspect the MRO before using it --- print("MRO:", [cls.__name__ for cls in ElectricCar.__mro__]) print() tesla = ElectricCar(make="Tesla", model="Model 3", year=2024, battery_kwh=82.0) print(tesla) # from Vehicle.__repr__ print(tesla.start_engine()) # overridden in ElectricCar print(tesla.honk()) # inherited from Car print(tesla.charge_status()) # ElectricCar's own method print(tesla.get_current_location()) # from GpsNavigationMixin print(tesla.navigate_to("Heathrow")) # from GpsNavigationMixin
2024 Tesla Model 3
Tesla Model 3 motor silently activated. ⚡
Beep beep!
Battery: 100% (82.0 kWh capacity)
51.5074° N, 0.1278° W (London, UK)
Navigating to 'Heathrow'... Turn right in 200m.
Method Overriding and Abstract Classes — Making Inheritance Safe by Design
Overriding a method means providing a new implementation in the child class that replaces the parent's version. Python picks the child's version first because of the MRO. But here's a subtle problem: what if you want to guarantee that every subclass MUST implement a particular method? Without enforcement, a developer could create a subclass, forget to implement process_payment(), and the bug only surfaces at runtime — potentially in production.
Python's abc module (Abstract Base Classes) fixes this at class-definition time. Mark a method with @abstractmethod and Python will refuse to let you instantiate any subclass that hasn't implemented it. You get a clear TypeError immediately, not a silent failure later.
This pattern is the backbone of frameworks like Django (Model, View), SQLAlchemy (base mappers), and any plugin system. Define the contract in the abstract base class. Every concrete implementation fulfils that contract. This is the 'O' in SOLID — Open/Closed Principle: open for extension, closed for modification.
from abc import ABC, abstractmethod from datetime import datetime class PaymentProcessor(ABC): """ Abstract base class — defines the CONTRACT for all payment processors. You cannot instantiate this class directly. Any subclass MUST implement the abstract methods or Python raises TypeError at class creation time. """ def __init__(self, merchant_id: str): self.merchant_id = merchant_id self._transaction_log: list = [] @abstractmethod def process_payment(self, amount: float, currency: str) -> dict: """ Every payment processor must define HOW it handles a payment. The what (a payment happens) is the contract. The how is up to each subclass. """ pass # abstract — no body needed @abstractmethod def refund(self, transaction_id: str) -> bool: """Every processor must support refunds.""" pass # Concrete method — shared by ALL subclasses, no override needed def log_transaction(self, transaction_id: str, amount: float, status: str) -> None: """Non-abstract: logging works the same for every processor.""" entry = { "id": transaction_id, "amount": amount, "status": status, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } self._transaction_log.append(entry) print(f"[LOG] Transaction {transaction_id}: {status} — £{amount:.2f}") def get_transaction_history(self) -> list: return self._transaction_log class StripeProcessor(PaymentProcessor): """Concrete implementation — fulfils the PaymentProcessor contract via Stripe.""" def process_payment(self, amount: float, currency: str) -> dict: # In reality: call stripe.PaymentIntent.create(...) transaction_id = f"stripe_txn_{int(datetime.now().timestamp())}" print(f"Stripe: charging £{amount:.2f} {currency} via card on file...") self.log_transaction(transaction_id, amount, "SUCCESS") return {"processor": "Stripe", "transaction_id": transaction_id, "status": "SUCCESS"} def refund(self, transaction_id: str) -> bool: # In reality: call stripe.Refund.create(payment_intent=transaction_id) print(f"Stripe: processing refund for {transaction_id}...") return True class PayPalProcessor(PaymentProcessor): """Different concrete implementation — same contract, completely different internals.""" def __init__(self, merchant_id: str, client_id: str): super().__init__(merchant_id) # call parent __init__ first self.client_id = client_id # PayPal-specific credential def process_payment(self, amount: float, currency: str) -> dict: transaction_id = f"pp_txn_{int(datetime.now().timestamp())}" print(f"PayPal: initiating £{amount:.2f} {currency} order via REST API...") self.log_transaction(transaction_id, amount, "PENDING") return {"processor": "PayPal", "transaction_id": transaction_id, "status": "PENDING"} def refund(self, transaction_id: str) -> bool: print(f"PayPal: submitting refund request for {transaction_id}...") return True # --- Prove the contract is enforced --- try: bad_processor = PaymentProcessor(merchant_id="test") # should fail except TypeError as error: print(f"Caught expected error: {error}\n") # --- Use the concrete classes polymorphically --- processors: list[PaymentProcessor] = [ StripeProcessor(merchant_id="merch_stripe_001"), PayPalProcessor(merchant_id="merch_pp_001", client_id="pp_client_abc"), ] for processor in processors: result = processor.process_payment(amount=49.99, currency="GBP") print(f"Result: {result}\n")
Stripe: charging £49.99 GBP via card on file...
[LOG] Transaction stripe_txn_1718000000: SUCCESS — £49.99
Result: {'processor': 'Stripe', 'transaction_id': 'stripe_txn_1718000000', 'status': 'SUCCESS'}
PayPal: initiating £49.99 GBP order via REST API...
[LOG] Transaction pp_txn_1718000001: PENDING — £49.99
Result: {'processor': 'PayPal', 'transaction_id': 'pp_txn_1718000001', 'status': 'PENDING'}
| Aspect | Single Inheritance | Multiple Inheritance |
|---|---|---|
| Syntax | class Child(Parent): | class Child(ParentA, ParentB): |
| Complexity | Low — straightforward to follow | Higher — requires MRO awareness |
| Method resolution | Linear — walks one parent chain | C3 Linearisation (MRO) resolves conflicts |
| Diamond Problem risk | None | Exists — Python's MRO handles it, but you must understand it |
| Best use case | Specialisation of a single type (Car → ElectricCar) | Mixing in orthogonal capabilities (GPS, Logging, Serialisation) |
| super() behaviour | Calls the one direct parent | Follows MRO — may call a sibling class, not just the parent |
| Risk of tight coupling | Medium | Higher — changes to either parent can affect the child unpredictably |
| Real-world examples | Django Model subclasses, SQLAlchemy mapped classes | Mixin patterns in Django (LoginRequiredMixin), Pytest plugins |
🎯 Key Takeaways
- Inheritance is justified only when the IS-A relationship is true — if you catch yourself saying 'has-a', switch to composition before you build a tangled hierarchy you can't escape.
- Always use super() instead of hardcoding the parent class name — super() is MRO-aware and won't silently break when your class hierarchy changes.
- Abstract Base Classes (ABC + @abstractmethod) are how you enforce contracts — they shift bugs from silent runtime failures to loud, immediate TypeErrors at class definition time.
- In multiple inheritance, super() does NOT always call 'the parent' — it calls the next class in the MRO. Print ClassName.__mro__ and understand it before you chain super() across mixins.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting to call super().__init__() in the child class — Symptom: attributes defined in the parent are missing, causing AttributeError when you access them on the child instance — Fix: always call super().__init__(args, *kwargs) as the first line of your child's __init__. It ensures the parent's setup runs before your child adds its own attributes on top.
- ✕Mistake 2: Using inheritance when composition is the right answer — Symptom: your class hierarchy becomes deep and brittle; a change to a grandparent breaks three unrelated children — Fix: ask 'IS-A or HAS-A?' A Dog IS-A Animal (inheritance is correct). A Car HAS-A Engine (composition is correct — Engine shouldn't be a parent class of Car). Prefer composition when the child would only use a fraction of the parent's interface.
- ✕Mistake 3: Assuming super() in multiple inheritance always calls the direct parent — Symptom: a method runs in a baffling order or runs twice, especially when super() is chained across mixin classes — Fix: print ClassName.__mro__ to see the exact resolution order before assuming which class super() will call next. In multiple inheritance, super() calls the NEXT class in the MRO, which may be a sibling mixin — not your parent class.
Interview Questions on This Topic
- QWhat is the Method Resolution Order (MRO) in Python, and how does Python's C3 Linearisation algorithm decide which parent's method gets called in a diamond inheritance scenario?
- QWhat's the difference between overriding a method and overloading it? Python doesn't support traditional overloading — how would you simulate it in a subclass?
- QIf you have `class C(A, B)` and both A and B define a method called `save()`, and A's `save()` calls `super().save()`, will B's `save()` ever get called? Walk me through why.
Frequently Asked Questions
What is the difference between single and multiple inheritance in Python?
Single inheritance means a class inherits from exactly one parent, keeping the hierarchy simple and easy to trace. Multiple inheritance means a class inherits from two or more parents simultaneously. Python handles method conflicts in multiple inheritance using the MRO (Method Resolution Order), calculated via the C3 Linearisation algorithm. Multiple inheritance is most useful for mixin patterns — adding isolated capabilities like logging or serialisation to a class without making it a subtype of those things.
When should I use inheritance versus composition in Python?
Use inheritance when you can honestly say 'Child IS-A Parent' — a SavingsAccount IS-A BankAccount. Use composition when you'd say 'Child HAS-A thing' — a Car HAS-A Engine, so Engine should be an attribute, not a parent class. Deep inheritance hierarchies become brittle quickly; composition keeps classes loosely coupled and individually testable.
What does super() actually do in Python, and is it just a shortcut for calling the parent class?
super() is not simply 'call the parent'. It returns a proxy object that delegates method calls to the next class in the MRO, not necessarily the direct parent. In single inheritance this distinction doesn't matter — the next class IS the parent. But in multiple inheritance, super() in a mixin might call a sibling class's method, enabling cooperative multiple inheritance where every class in the chain gets to participate. This is why all classes in a cooperative hierarchy must call super().__init__() even if they don't need it themselves.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.