Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+200 XP

Inheritance and Polymorphism

Single inheritance, super(), method overriding, real polymorphism, the method resolution order, isinstance vs type ==, and composition over inheritance.

45 min August 2026
// Part 01 — What Inheritance Actually Buys You

Building One Class on Top of Another

By the end of Module 20 you can build a self-contained class with its own attributes and behavior. Real systems, though, are full of things that are variations on a theme — different kinds of employees, different kinds of payments, different kinds of shapes — that share a large amount of common structure and behavior, but differ in specific, well-defined ways. Inheritance lets one class (a subclass, or child class) automatically receive all the attributes and methods of another class (a superclass, base class, or parent class), and then add or override only what actually differs.

Single inheritance — the basic syntax
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def describe(self):
        return f"{self.name} earns ${self.salary}"


class Manager(Employee):        # Manager inherits from Employee
    pass


alice = Manager("Alice", 110000)
print(alice.describe())          # "Alice earns $110000" — inherited, unmodified, from Employee
print(isinstance(alice, Employee))  # True — a Manager IS an Employee

Even with an empty body (pass), Manager already has everything Employee has — the entire point of inheritance is not writing that behavior a second time. The relationship inheritance expresses is often called an "is-a" relationship: a Manager is an Employee, with some additional specifics. This phrase will matter directly in Part 06, when it is contrasted with a different, equally important relationship: "has-a."

// Part 02 — super().__init__()

super() — Reaching Back Into the Parent Class

A subclass almost always needs its own additional data on top of what the parent already sets up — a Manager needs a list of direct reports; an Employee alone does not. This means the subclass typically needs its own __init__. Simply redefining __init__ from scratch would mean re-writing the parent's setup logic all over again — exactly the duplication inheritance exists to avoid. super() gives you a handle on the parent class so you can call its methods, most commonly its __init__, and let it do its own setup first.

super().__init__() — letting the parent do its own setup
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary


class Manager(Employee):
    def __init__(self, name, salary, direct_reports):
        super().__init__(name, salary)   # Employee.__init__ runs first, sets name and salary
        self.direct_reports = direct_reports   # Manager adds its own extra attribute

alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.name, alice.salary, alice.direct_reports)
# Alice 110000 ['Bob', 'Carla']

super().__init__(name, salary) is exactly equivalent in effect to writing self.name = name and self.salary = salary directly inside Manager.__init__ — but written this way, if Employee.__init__ ever changes (say, a validation rule is added, exactly like Module 20's salary check), Manager automatically picks up that change for free, with zero edits needed in Manager itself.

⚠️ Important
Almost always call super().__init__() as the first line of a subclass's __init__. If you skip it, the parent class's setup logic simply never runs — attributes the parent normally sets up will not exist on your subclass instance, leading to confusing AttributeErrors later, often far away from where the actual mistake was made.
// Part 03 — Method Overriding

Redefining a Method to Change Its Behavior in a Subclass

A subclass can redefine any method it inherits, simply by defining a method of the same name. This is called overriding. The subclass's version takes priority whenever the method is called on an instance of that subclass — the parent's original version is not deleted, it is just no longer what gets found first.

Overriding a method entirely
class Employee:
    def describe(self):
        return f"{self.name} earns ${self.salary}"


class Manager(Employee):
    def __init__(self, name, salary, direct_reports):
        super().__init__(name, salary)
        self.direct_reports = direct_reports

    def describe(self):   # OVERRIDES Employee.describe entirely
        return f"{self.name} manages {len(self.direct_reports)} people and earns ${self.salary}"


alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.describe())
# "Alice manages 2 people and earns $110000" — the Manager version runs, not Employee's

A common, safer variant extends the parent's behavior rather than replacing it entirely — calling super().method_name() from inside the override to reuse the parent's logic, then adding to it, exactly the same idea as super().__init__() but applied to any method, not just the constructor.

Extending, rather than replacing, the parent's behavior
class Manager(Employee):
    def describe(self):
        base = super().describe()          # reuse Employee's version
        return f"{base} and manages {len(self.direct_reports)} people"

alice = Manager("Alice", 110000, ["Bob", "Carla"])
print(alice.describe())
# "Alice earns $110000 and manages 2 people"
// Part 04 — Polymorphism, With a Real Example

Same Method Name, Different Behavior Per Subclass

Polymorphism ("many forms") is the ability to call the same method name on objects of different classes and have each one respond with its own correct, type-specific behavior — without the calling code needing to know or care which specific subclass it is actually holding. Here is a concrete, realistic example: a fictional fintech in Denver processing payments through several different providers, each with genuinely different internal logic, but a shared, uniform interface.

Polymorphism — a real payment-processing example
class Payment:
    def __init__(self, amount):
        self.amount = amount

    def process(self):
        raise NotImplementedError("Subclasses must implement process()")


class CreditCardPayment(Payment):
    def __init__(self, amount, card_number):
        super().__init__(amount)
        self.card_number = card_number

    def process(self):
        return f"Charging ${self.amount} to card ending in {self.card_number[-4:]}"


class ACHPayment(Payment):
    def __init__(self, amount, routing_number):
        super().__init__(amount)
        self.routing_number = routing_number

    def process(self):
        return f"Initiating ACH transfer of ${self.amount} via routing {self.routing_number}"


class WalletPayment(Payment):
    def __init__(self, amount, wallet_id):
        super().__init__(amount)
        self.wallet_id = wallet_id

    def process(self):
        return f"Deducting ${self.amount} from wallet {self.wallet_id}"

The real payoff of polymorphism shows up in code that processes a batch of mixed payment types without ever branching on type — every object simply gets asked to .process() itself, correctly, regardless of which concrete subclass it actually is:

Calling .process() uniformly across different subclasses — no type-checking needed
payments = [
    CreditCardPayment(49.99, "4111111111111111"),
    ACHPayment(1200.00, "021000021"),
    WalletPayment(15.50, "wallet_882"),
]

for payment in payments:
    print(payment.process())

# Charging $49.99 to card ending in 1111
# Initiating ACH transfer of $1200.0 via routing 021000021
# Deducting $15.5 from wallet wallet_882

This loop has no if isinstance(payment, CreditCardPayment): branching anywhere — it does not need to know or care what kind of payment it is holding. Each object already knows how to process itself correctly. This is genuinely the practical value polymorphism delivers: adding a new payment type later (say, CryptoPayment) means writing one new subclass — the loop above needs zero changes to support it.

// Part 05 — MRO and Multiple Inheritance

The Method Resolution Order — And Why Multiple Inheritance Deserves Caution

Python supports multiple inheritance — a class can inherit from more than one parent class at once, by listing several base classes in the class definition. When multiple parents could each provide a method of the same name, Python needs a deterministic rule for deciding which one wins. That rule is called the Method Resolution Order (MRO), and you can inspect it directly on any class.

Multiple inheritance and the MRO
class Flyable:
    def move(self):
        return "Flying"

class Swimmable:
    def move(self):
        return "Swimming"

class Duck(Flyable, Swimmable):   # inherits from BOTH
    pass

d = Duck()
print(d.move())          # "Flying" — Flyable is listed first, so it wins
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)
# This is the exact left-to-right order Python searches for a method

Python computes the MRO using an algorithm called C3 linearization, which guarantees a consistent, predictable order even in fairly complex inheritance hierarchies. The practical rule of thumb: parents listed earlier in class Duck(Flyable, Swimmable): take priority when there is a naming conflict.

⚠️ Important
An honest opinion: multiple inheritance is genuinely powerful, but it should be used sparingly in real code. As hierarchies grow, tracing exactly which parent a given method actually came from gets meaningfully harder, and naming collisions between unrelated parent classes become a real source of subtle bugs. The pattern that actually works well in practice is mixins — small classes that provide one specific, self-contained piece of reusable behavior (like a SerializableMixin adding a .to_json() method, or a ComparableMixin adding ordering methods), designed from the start to be combined with other classes without depending on shared state or stepping on each other's method names.
// Part 06 — isinstance() vs type() ==

Checking an Object's Type the Correct Way

There are two common ways to check what "kind of thing" an object is, and they behave differently once inheritance is in the picture. isinstance(obj, Class) checks whether obj is an instance of Class or any subclass of it. type(obj) == Class checks for an exact type match only, ignoring the entire inheritance hierarchy.

The difference, made concrete
alice = Manager("Alice", 110000, ["Bob"])

print(isinstance(alice, Manager))    # True  — exact type match
print(isinstance(alice, Employee))   # True  — Manager IS an Employee, via inheritance
print(type(alice) == Manager)         # True
print(type(alice) == Employee)         # False — type() == ignores inheritance entirely, even though
                                          # a Manager genuinely is a kind of Employee

isinstance() is almost always the correct choice, precisely because it respects inheritance. A function written to accept "any Employee" should work correctly for a Manager, a Contractor, or any other subclass — that is the entire promise of the "is-a" relationship from Part 01. type(obj) == Employee would reject a perfectly valid Manager instance simply because it is not exactly the Employee class, breaking polymorphism for no good reason.

🎯 Pro Tip
isinstance() also accepts a tuple of types, checking whether the object matches any of them: isinstance(payment, (CreditCardPayment, ACHPayment)). This is the idiomatic way to check "is this one of these several types," rather than chaining multiple or conditions.
// Part 07 — Composition Over Inheritance

"Favor Composition Over Inheritance" — What This Actually Means

Inheritance models an "is-a" relationship. Many real design problems are actually a "has-a" relationship in disguise — and forcing a "has-a" relationship into inheritance produces fragile, awkward class hierarchies. Composition means building a class by holding an instance of another class as an attribute, rather than inheriting from it, and delegating to it as needed.

Inheritance misused to model a has-a relationship
# Awkward: a Car does not genuinely "is-a" Engine — it HAS an engine.
class Engine:
    def start(self):
        return "Engine starting"

class Car(Engine):        # modeling "has-a" as inheritance — a poor fit
    def drive(self):
        return f"{self.start()} — now driving"

# This forces every Car to BE an Engine in the type system, which is semantically wrong,
# and it means Car inherits every Engine method whether or not that makes sense for a car.
The composition fix — a Car HAS an Engine, correctly modeled
class Engine:
    def start(self):
        return "Engine starting"

class Car:
    def __init__(self):
        self.engine = Engine()   # Car HOLDS an Engine — composition, not inheritance

    def drive(self):
        return f"{self.engine.start()} — now driving"

my_car = Car()
print(my_car.drive())   # "Engine starting — now driving"
print(isinstance(my_car, Engine))   # False — correctly, a Car is not an Engine

The composition version is more honest about the actual relationship, and considerably more flexible: swapping in an ElectricEngine later means changing one line inside Car.__init__, with zero changes to the type hierarchy. It also avoids a real, common problem with deep inheritance chains — a subclass five levels down that has silently inherited a dozen methods that make no sense for it, purely as a side effect of the class it happened to be built on top of.

💡 Note
"Favor composition over inheritance" does not mean inheritance is wrong — Part 04's Payment hierarchy is a genuinely good use of inheritance, because CreditCardPayment really is a Payment, sharing real, meaningful structure and an intentionally uniform interface. The guidance is about defaulting to composition when the relationship is genuinely "has-a," and reaching for inheritance specifically when the relationship is genuinely "is-a" and you want that shared, substitutable interface — exactly the difference this module has now shown from both directions.
// Part 08 — Real World
💼 What This Looks Like at Work

A Chicago Fintech Adds a Fourth Payment Type in One Pull Request

Scenario — Fintech startup, Chicago · Feature launch

A fintech startup built its checkout flow exactly on the pattern from Part 04 — a Payment base class with CreditCardPayment, ACHPayment, and WalletPayment subclasses, each with their own .process() implementation. Business development closes a partnership deal requiring support for a fourth payment type: Buy-Now-Pay-Later installment plans.

What the engineer actually had to change

One new subclass, following the exact same shape as the other three:

Adding a new payment type — zero changes to any existing code
class InstallmentPayment(Payment):
    def __init__(self, amount, num_installments):
        super().__init__(amount)
        self.num_installments = num_installments

    def process(self):
        per_installment = self.amount / self.num_installments
        return f"Splitting ${self.amount} into {self.num_installments} payments of ${per_installment:.2f}"

The checkout loop that calls payment.process() across a mixed batch of payment objects — the exact loop from Part 04 — required zero code changes. It had no branching on payment type to begin with, so a fourth type simply slotted into the existing polymorphic call site.

Why the isinstance() discipline mattered here too

A separate part of the codebase — the fraud-review queue — had, months earlier, been written with a check reading if type(payment) == CreditCardPayment or type(payment) == ACHPayment: to decide which payments needed manual review. Because it used type() == instead of isinstance(), adding InstallmentPayment silently bypassed fraud review entirely — it matched neither exact-type check, and the flawed code treated "not explicitly listed" as "does not need review." The fix, once found, was switching to isinstance(payment, RequiresReviewMixin) against a small mixin each risky payment type explicitly opted into — a pattern immune to this exact class of bug, since new subclasses have to actively declare that they need review rather than being silently excluded by an incomplete list of exact-type checks.

// Part 09 — Misconceptions

Four Misconceptions About Inheritance and Polymorphism

✕ ""super().__init__() is optional boilerplate you can skip if the subclass doesn't need anything from it""
Skipping it means the parent class's setup logic never runs at all — attributes the parent normally sets up simply will not exist on the subclass instance. Unless a subclass genuinely needs none of the parent's state, super().__init__() should be the first line of its own __init__.
✕ ""Polymorphism means a method can return different types""
Polymorphism means the SAME method name can be called on objects of different classes, each responding with its own correct, type-specific behavior — as shown with .process() across four different Payment subclasses in Parts 04 and 08. It is about uniform calling code working correctly across different concrete types, not about a single method's return type varying.
✕ ""type(obj) == SomeClass is basically the same as isinstance(obj, SomeClass)""
type() == checks for an exact type match only, completely ignoring inheritance. isinstance() also returns True for any subclass. As shown in Part 08, using type() == in place of isinstance() is a real, documented category of production bug — new subclasses get silently excluded from logic that was supposed to apply to them.
✕ ""Multiple inheritance is a core, everyday tool in professional Python code""
It is supported and occasionally useful, but as covered in Part 05, it is used sparingly in practice because tracing method origins and naming collisions across multiple parents gets genuinely harder as hierarchies grow. The pattern that works well is small, focused mixins — not deep, tangled multi-parent hierarchies.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

What does super().__init__() do, and why is it usually the first line of a subclass's __init__?
It calls the parent class's __init__, letting it perform its own setup (typically assigning the attributes it is responsible for) before the subclass adds anything specific to itself. Calling it first ensures the parent's attributes exist before the subclass's own __init__ logic runs, and it means the subclass automatically benefits from any future changes to the parent's setup logic without needing to duplicate it.
Explain polymorphism with a concrete example.
Polymorphism is the ability to call the same method name on objects of different classes and have each respond with its own correct behavior, without the calling code needing to know which specific subclass it holds. For example, calling .process() on a list containing CreditCardPayment, ACHPayment, and WalletPayment objects runs each subclass's own implementation correctly, in a single uniform loop with no type-checking branches.
What is the difference between isinstance(obj, Class) and type(obj) == Class?
isinstance() returns True if obj is an instance of Class OR any subclass of it, respecting the inheritance hierarchy. type(obj) == Class checks for an exact type match only and ignores inheritance entirely — it returns False for a subclass instance even though that instance genuinely "is-a" Class. isinstance() is almost always the correct choice for type checks in Python.
What is the Method Resolution Order (MRO), and when does it matter?
The MRO is the deterministic, left-to-right order Python uses to search for a method or attribute across a class and all of its parent classes, visible via ClassName.__mro__. It matters specifically with multiple inheritance, where more than one parent could provide a method of the same name — the parent listed earliest in the class definition takes priority.
What does "favor composition over inheritance" mean, and when would you choose composition instead?
It means modeling a "has-a" relationship (a Car has an Engine) by holding an instance of another class as an attribute and delegating to it, rather than modeling it as inheritance (a Car "is-a" Engine), which is usually a poor semantic fit and creates a rigid, hard-to-change type hierarchy. Composition is the right choice whenever the relationship is genuinely "has-a" rather than "is-a" — inheritance remains the right tool when the relationship is genuinely "is-a" and a shared, substitutable interface (as with polymorphism) is actually needed.
// Common Mistakes

Inheritance Mistakes Beginners Make Constantly

Forgetting to call super().__init__() in a subclass constructor
The parent class's attributes never get set up, leading to AttributeError later, often at a call site far away from the missing super() call. Make super().__init__() the first line of any subclass __init__ that needs the parent's state.
Using type(obj) == Class instead of isinstance() for type checks
As covered in Parts 06 and 08, this silently breaks for subclasses, since type() == ignores inheritance entirely. Use isinstance(), which correctly recognizes subclass instances too.
Reaching for inheritance to model a "has-a" relationship
A Car inheriting from Engine, purely to reuse the start() method, forces a semantically wrong "is-a" relationship into the type system. Model "has-a" relationships with composition — holding an instance of the other class as an attribute — instead.
Building deep, tangled multiple-inheritance hierarchies
As the number of parent classes and inheritance levels grows, tracing which parent a method actually came from, and resolving naming collisions between unrelated parents, becomes genuinely hard to reason about. Prefer small, focused mixins over deep multi-parent chains.
// Error Library

Errors You Will Hit With Inheritance — And Exactly Why

AttributeError: 'Manager' object has no attribute 'salary'
Cause: The subclass's __init__ never called super().__init__(), so the parent class's attribute-setting logic never ran for this instance.
Fix: Add super().__init__(...) as the first line of the subclass's __init__, passing through whatever arguments the parent's __init__ requires.
NotImplementedError: Subclasses must implement process()
Cause: A base class method deliberately raises this to signal that subclasses are required to override it, and this specific instance is either the base class itself or a subclass that forgot to provide its own implementation.
Fix: Define the method in the subclass. This is a deliberate design signal, not a bug in the base class — it exists to make a missing override loud instead of silent.
TypeError: __init__() missing 1 required positional argument: 'direct_reports'
Cause: The subclass added a new required parameter to its __init__, but existing calling code is still creating instances with only the parent class's original arguments.
Fix: Update the calling code to pass the new required argument, or give it a default value in the subclass's __init__ if it should be optional.
TypeError: Cannot create a consistent method resolution order (MRO) for bases ...
Cause: A multiple-inheritance class definition creates a contradictory ordering requirement — most commonly from inheriting the same base class through two different, conflicting paths.
Fix: Simplify the inheritance structure. This is usually a sign the hierarchy has become genuinely too tangled — consider whether composition or a simpler mixin structure would avoid the conflict entirely.

🎯 Key Takeaways

  • Inheritance (class Subclass(Parent):) lets a class receive all of another class's attributes and methods, modeling a genuine "is-a" relationship.
  • super().__init__() lets a subclass reuse the parent's setup logic instead of duplicating it — it should almost always be the first line of a subclass's own __init__.
  • Overriding a method (defining a method of the same name in the subclass) replaces the parent's version; calling super().method() from inside an override lets you extend rather than fully replace it.
  • Polymorphism means calling the same method name on objects of different classes and getting each one's own correct behavior — calling code never needs to branch on type.
  • isinstance(obj, Class) respects inheritance (True for subclasses too); type(obj) == Class checks for an exact match only and silently excludes subclasses — isinstance() is almost always the right choice.
  • The Method Resolution Order (MRO), visible via ClassName.__mro__, is the deterministic order Python searches for methods across multiple inheritance — earlier-listed parents win naming conflicts.
  • Multiple inheritance is powerful but should be used sparingly; small, focused mixins are the pattern that works well in real codebases.
  • Favor composition (holding another class as an attribute) over inheritance whenever the relationship is genuinely "has-a" rather than "is-a".

What comes next

Module 22 covers Python's convention-based privacy, and the dunder methods — __str__, __repr__, __eq__, __len__, and operator overloading — that let your own objects behave like Python's built-in types.

Module 22 → Encapsulation and Magic/Dunder Methods
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...