Skip to content
1stepGrowLearnCompareGrow

Inheritance and Composition in Python: OOP in Python, Part 2

Inheritance and composition in Python with tested code: when to use each, how super() really works, duck typing, and why deep class hierarchies backfire.

Althaf Ashraf

AI Systems Engineer, Tata Consultancy Services

9 min readUpdated
Share
On this page

Inheritance and composition in Python are the two ways classes relate to each other. Inheritance makes one class a specialised version of another, while composition builds a class from other objects it holds. Prefer composition by default and use inheritance for genuine "is a" relationships kept shallow.

The syntax is the easy part. The design mistakes cost more: naming the parent class instead of calling super() can silently skip a class under multiple inheritance, and a five-level hierarchy makes it hard to find which class a method comes from. This part is for readers who know Python classes and objects from Part 1. It covers super() and the MRO, composition, duck typing, class and static methods and abstract base classes, with a table for choosing between them.

How does inheritance work in Python?

A subclass gets the parent's attributes and methods, and can add or override them:

Python
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

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

    def annual_cost(self):
        return self.salary * 12


class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)     # run the parent's setup
        self.reports = reports

    def describe(self):                     # override
        base = super().describe()
        return f"{base} and manages {len(self.reports)} people"
Python
m = Manager("Rekha", 180_000, ["Amit", "Sara"])
print(m.describe())      # Rekha earns 180,000 and manages 2 people
print(m.annual_cost())   # 2160000  - inherited unchanged

Two things to note. First, super().__init__(...) runs the parent initialiser rather than duplicating it. Second, super().describe() inside the override lets you extend behaviour instead of replacing it.

Forget the super().__init__ call and the parent's attributes never get set. Accessing m.name would then raise AttributeError: 'Manager' object has no attribute 'name'.

Why use super() instead of naming the parent?

Always use super() rather than Employee.__init__(self, ...). The super() documentation describes it as returning a proxy that delegates to "a parent or sibling class", following the method resolution order (MRO). Under multiple inheritance, the next class is not necessarily the one you would have named:

Python
class Base:
    def greet(self):
        return "Base"

class Logged(Base):
    def greet(self):
        return "Logged > " + super().greet()

class Cached(Base):
    def greet(self):
        return "Cached > " + super().greet()

class Service(Logged, Cached):
    def greet(self):
        return "Service > " + super().greet()

print(Service().greet())
# Service > Logged > Cached > Base
print([k.__name__ for k in Service.__mro__])
# ['Service', 'Logged', 'Cached', 'Base', 'object']

Inside Logged, super() points to Cached, a sibling, not to Base. Consequently every class runs exactly once. Had Logged called Base.greet(self) directly, Cached would have been skipped. The tutorial section on multiple inheritance explains how the order is computed.

Why does composition usually win over inheritance?

Inheritance couples classes tightly. The subclass depends on the parent's internals, and changing the parent can break children in non-obvious ways. This is known as the "fragile base class" problem.

Composition holds a reference instead:

Python
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return "Engine started"


class ElectricMotor:
    def __init__(self, kilowatts):
        self.kilowatts = kilowatts

    def start(self):
        return "Motor humming"


class Car:
    def __init__(self, model, power_unit):
        self.model = model
        self.power_unit = power_unit      # has-a, not is-a

    def start(self):
        return f"{self.model}: {self.power_unit.start()}"
Python
print(Car("Nexon", Engine(120)).start())          # Nexon: Engine started
print(Car("Nexon EV", ElectricMotor(95)).start()) # Nexon EV: Motor humming

The power unit is swappable at runtime, and Car does not depend on either class's internals. Testing is also easy, because you can pass a fake object with a start method.

The heuristic: if you would say "is a", consider inheritance. If you would say "has a", use composition. Most real relationships are "has a".

Inheritance and composition in Python compared

Question Inheritance Composition Duck typing
Relationship "is a" "has a" "behaves like"
Coupling Tight: subclass sees parent internals Loose: talks through methods None: only the method name matters
Swap behaviour at runtime No Yes Yes
Code reuse Automatic Explicit delegation None
Typical Python use Custom exceptions, framework base classes Services, pipelines, models with parts Exporters, file-like objects, plugins

How does duck typing remove the need for inheritance?

Python does not require a shared base class for polymorphism. If two objects have the same method, they are interchangeable:

Python
import json

class CSVExporter:
    def export(self, rows):
        return "\n".join(",".join(map(str, r)) for r in rows)

class JSONExporter:
    def export(self, rows):
        return json.dumps(rows)

def save(exporter, rows):
    return exporter.export(rows)      # neither class inherits from anything

rows = [[1, "a"], [2, "b"]]
print(save(CSVExporter(), rows))      # 1,a  then  2,b  on the next line
print(save(JSONExporter(), rows))     # [[1, "a"], [2, "b"]]

In a statically typed language this would need an interface. In Python, having the method is enough, which removes a large share of the inheritance you might otherwise write. If you want a type checker to verify the shape, typing.Protocol describes it without forcing the classes to inherit anything.

What is the difference between a class method and a static method?

A classmethod receives the class as cls and suits alternative constructors; a staticmethod receives nothing and is a plain function kept in the class:

Python
class Date:
    def __init__(self, day, month, year):
        self.day, self.month, self.year = day, month, year

    @classmethod
    def from_string(cls, text):
        d, m, y = map(int, text.split("-"))
        return cls(d, m, y)          # cls, so subclasses get their own type

    @staticmethod
    def is_leap(year):
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
Python
d = Date.from_string("15-08-2026")
print(d.day, d.month, d.year)        # 15 8 2026
print(Date.is_leap(2028))            # True
print(Date.is_leap(2100))            # False

class IndianDate(Date):
    pass

print(type(IndianDate.from_string("15-08-2026")).__name__)   # IndianDate

A classmethod receives the class and is the idiomatic way to write alternative constructors. Using cls(...) rather than Date(...) means a subclass calling from_string gets an instance of itself, as the last line shows.

By contrast, a staticmethod receives nothing automatically. It is a plain function that lives in the class for organisational reasons.

When are abstract base classes worth it?

Use one when you want to define an interface and fail loudly if it is not implemented:

Python
from abc import ABC, abstractmethod

class Model(ABC):
    @abstractmethod
    def fit(self, X, y): ...

    @abstractmethod
    def predict(self, X): ...

    def score(self, X, y):              # concrete, shared by all subclasses
        preds = self.predict(X)
        return sum(p == t for p, t in zip(preds, y)) / len(y)


class Baseline(Model):
    def fit(self, X, y):
        self.majority = max(set(y), key=list(y).count)
        return self

    def predict(self, X):
        return [self.majority] * len(X)
Python
X = [[0], [1], [2], [3]]
y = ["spam", "ham", "ham", "ham"]
print(Baseline().fit(X, y).score(X, y))   # 0.75

Model()
# TypeError: Can't instantiate abstract class Model without an
# implementation for abstract methods 'fit', 'predict'

As the abc module documentation states, a class with abstract methods cannot be instantiated until all of them are overridden. A subclass missing predict therefore fails at instantiation rather than deep inside a call stack later, which is the whole point. The exact wording of the error message varies slightly between Python versions.

Common mistakes with inheritance

  • Forgetting super().__init__(). The parent's attributes are never set.
  • Hardcoding the parent class. Employee.__init__(self, ...) breaks cooperative multiple inheritance.
  • Inheriting to reuse one helper method. Move the helper into a function or a component instead.
  • Returning Date(...) from a classmethod. Subclasses then get the wrong type; return cls(...).
  • Deep hierarchies. Five levels of subclasses make it hard to find which class implements a method.

How deep should an inheritance hierarchy go?

Two levels is usually plenty. The most common OOP failure in real codebases is not wrong syntax. Instead, it is a five-level inheritance chain where finding which class actually implements a method requires reading all five.

If you need more, that is a strong signal that some of those relationships are "has a" and want composition instead.

Practical guidance: prefer functions to classes, prefer composition to inheritance, prefer shallow to deep, and only add abstraction when the duplication actually hurts.

The Sunday Growth Brief

One email a week: the best new comparisons, a fresh roadmap and the tech news worth your attention.

No spam. Unsubscribe in one click.

Python classes and objects (OOP Part 1) covers classes, instances and dunder methods. Custom error hierarchies are a good place to practise inheritance, as shown in exception handling in Python. For functional patterns, see lambda functions and iterators and generators.

Frequently asked questions

What is the difference between inheritance and composition?

Inheritance says a Manager is an Employee and gets its behaviour automatically. Composition says a Car has an Engine and delegates to it. Composition is usually more flexible, because you can swap the component at runtime and the two classes do not depend on each other's internals, which also makes testing with fakes easy.

What does super() actually do?

super() returns a proxy object that delegates method calls to the next class in the method resolution order. Under multiple inheritance that next class can be a sibling rather than the direct parent. That is precisely why you should use super() instead of hardcoding ParentClass.method(self), which skips classes and breaks cooperative designs.

What is the difference between a class method and a static method?

A classmethod receives the class itself as its first argument, usually named cls, and is typically used for alternative constructors such as from_string. A staticmethod receives nothing automatically. It is really just a plain function grouped inside the class for organisation, and it cannot see the class or instance unless you pass them.

Do I need abstract base classes?

Rarely in small codebases. They earn their place when you are defining an interface that several people or plugins will implement, and you want a clear TypeError at instantiation rather than an AttributeError deep in a call stack. For lighter-weight checking with type checkers, typing.Protocol describes the same interface without inheritance.

Written by

Althaf Ashraf

AI Systems Engineer, Tata Consultancy Services

AI systems engineer working on agentic decision systems and retrieval architectures at TCS, with a focus on getting AI into real workflows rather than demos.

Agentic AIRetrieval-Augmented GenerationLangChainDecision modelling

Related reading

Free weekly newsletter

Get the shortlist before everyone else

Every Sunday we send one email with the week’s sharpest course comparison, a career roadmap worth stealing, and the tech news and hiring signals we’re watching.

  • No fluff, ever
  • Unsubscribe anytime
  • 5-minute read

We never share your address. One click to leave.