Skip to main content
Python · lesson 17 of 34
Python intermediate Lesson 17 of 34

Composition vs Inheritance in Python

Compare Python's has-a composition against is-a inheritance, and see how composition avoids the fragile base class problem.

By SkillByExample · Updated

Inheritance asks “is this a specialized version of that?” Composition asks “does this own one of those?” Reaching for inheritance by default, even when the relationship is really “has-a,” leads to classes that expose more than they should. Output below is from Python 3.13.

Has-a: Building With Composition

Car doesn’t become an Engine — it owns one, and delegates to it.

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

    def start(self):
        return f"Engine ({self.horsepower} hp) starting..."

class Car:
    def __init__(self, make, horsepower):
        self.make = make
        self.engine = Engine(horsepower)   # "has-a" - Car owns an Engine, doesn't become one

    def start(self):
        return f"{self.make}: {self.engine.start()}"

car = Car("Tata", 120)
print(car.start())

# Swap the composed part at runtime - impossible with inheritance, trivial with composition
car.engine = Engine(300)
print(car.start())
$ python composition.py
Tata: Engine (120 hp) starting...
Tata: Engine (300 hp) starting...

Car only exposes what it chooses to — here, start(). Swapping car.engine for a different Engine instance changes behavior instantly, with no new class and no subclassing.

The Fragile Base Class Problem

Inheriting from a built-in like list to reuse its behavior looks convenient, but it inherits everything, including methods that break the abstraction you were trying to build.

class Stack(list):                 # inheritance: Stack "is-a" list - inherits EVERYTHING
    def push(self, item):
        self.append(item)

s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99)                     # list's insert() leaked through - breaks stack discipline
print(list(s))

class SafeStack:                    # composition: SafeStack "has-a" list, exposes only what it wants
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

    def __repr__(self):
        return f"SafeStack({self._items})"

safe = SafeStack()
safe.push(1)
safe.push(2)
print(safe)
print(hasattr(safe, "insert"))      # no leaked list methods to misuse
$ python fragile_base.py
[99, 1, 2]
SafeStack([1, 2])
False

Stack(list) was supposed to only support push, but subclassing list inherited insert(), sort(), remove(), and everything else list has — nothing stops a caller from using insert(0, ...) to violate stack order. SafeStack wraps a list internally instead of becoming one, so it only offers push/pop — hasattr(safe, "insert") confirms nothing extra leaked through.

Practice

1. Stack(list) already inherits list.pop() for free. Call it, then confirm it also inherits list.reverse(), another method a real stack shouldn’t expose.

Solution
class Stack(list):
    def push(self, item):
        self.append(item)

s = Stack()
s.push(1)
s.push(2)
print(s.pop())          # inherited list.pop() happens to behave like a stack
s.push(3)
s.push(4)
s.reverse()             # also inherited, and definitely not stack behavior
print(list(s))
$ python leaked_methods.py
2
[4, 3, 1]

pop() works by luck: list.pop() removes the last item, which is what a stack wants. reverse() comes along too and silently flips the order, so the item that was on top is now at the bottom.

2. Give Car a second composed part, Transmission, and have start() report both.

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

    def start(self):
        return f"Engine ({self.horsepower} hp) starting..."

class Transmission:
    def __init__(self, kind):
        self.kind = kind

class Car:
    def __init__(self, make, horsepower, transmission_kind):
        self.make = make
        self.engine = Engine(horsepower)
        self.transmission = Transmission(transmission_kind)

    def start(self):
        return f"{self.make}: {self.engine.start()} ({self.transmission.kind} transmission)"

print(Car("Tata", 120, "automatic").start())
$ python transmission.py
Tata: Engine (120 hp) starting... (automatic transmission)

A car “has an” engine and “has a” transmission; neither relationship is “is-a”, so each becomes an attribute rather than a base class.

3. Give SafeStack a __len__ and an is_empty() method, keeping the same “only expose what’s needed” discipline.

Solution
class SafeStack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

    def is_empty(self):
        return len(self._items) == 0

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

s = SafeStack()
print(s.is_empty(), len(s))
s.push(1)
print(s.is_empty(), len(s))
$ python safe_stack_len.py
True 0
False 1

Defining __len__ is what lets the built-in len() work on the wrapper. Each capability is opted into explicitly, unlike the subclass, which got all of list at once.

4. Give a new ElectricMotor class the same start() method as Engine, then show Car works with either one interchangeably.

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

    def start(self):
        return f"Engine ({self.horsepower} hp) starting..."

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

    def start(self):
        return f"Electric motor ({self.kwh} kWh) silently starting..."

class Car:
    def __init__(self, make, horsepower):
        self.make = make
        self.engine = Engine(horsepower)

    def start(self):
        return f"{self.make}: {self.engine.start()}"

car = Car("Tata", 120)
print(car.start())
car.engine = ElectricMotor(75)   # Car never checks the type, only that .start() exists
print(car.start())
$ python electric.py
Tata: Engine (120 hp) starting...
Tata: Electric motor (75 kWh) silently starting...

Car never inspects what self.engine is; it only calls .start(). That is duck typing from the polymorphism lesson, and it is why composed parts are easy to swap.

Frequently Asked Questions

What's the difference between composition and inheritance?
Inheritance models an "is-a" relationship — a SavingsAccount is a BankAccount. Composition models a "has-a" relationship — a Car has an Engine as an attribute, rather than becoming one through subclassing.
Why is composition often preferred over inheritance?
Composition only exposes the methods a class explicitly delegates to, while inheritance exposes everything the base class defines — including behavior the subclass never intended to inherit. This is called the fragile base class problem.
Is inheritance ever the right choice?
Yes, when the relationship genuinely is "is-a" and you want the subclass to be usable anywhere the base class is expected (substitutability). Abstract base classes and mixins, covered elsewhere in this series, are both legitimate uses of inheritance.
Can I swap a composed object at runtime?
Yes — that's one of composition's main advantages. Since the composed object is just an attribute, reassigning it (e.g. car.engine = Engine(300)) changes behavior instantly, something inheritance can't do without creating a new object of a different subclass.