When should you use composition instead of inheritance in Python, and what are the design signals for each?
Inheritance models an 'is-a' relationship and is appropriate when a subclass is genuinely a specialisation of the parent. Composition models a 'has-a' relationship and is preferred when you want to reuse behaviour without coupling to a class hierarchy — it is more flexible, easier to test, and avoids the fragile base-class problem.
How to think about it
The classic framing is is-a versus has-a. A Dog is an Animal, so inheritance fits; a Car has an Engine, so composition fits. But the framing isn’t the hard part — recognising when inheritance is quietly being abused is, and that is exactly the judgement an interviewer is probing.
Behind the question sit two ideas. The Liskov Substitution Principle asks: can I drop a subclass in anywhere its parent is expected and have nothing surprising happen? The fragile base-class problem asks: what breaks in my subclass when someone changes the parent? Composition sidesteps both, because it reuses behaviour by holding an object rather than inheriting from it.
A worked example
Watch the fragile base class first. log_and_flush calls self.log — and once a subclass overrides log, that internal call silently routes to the override, behaviour the base class never intended:
# Fragile inheritance: a base method calls another method the child overrode
class CSVLogger:
def log(self, msg):
print(f"CSV: {msg}")
def log_and_flush(self, msg):
self.log(msg) # silently calls the child's override
print("(flush)")
class PrefixedLogger(CSVLogger):
def log(self, msg):
super().log(f"[PREFIX] {msg}")
PrefixedLogger().log_and_flush("hello")
print()
# Composition: the Pipeline HAS-A logger, injected from outside
class JSONLogger:
def log(self, msg):
import json
print(json.dumps({"msg": msg}))
class Pipeline:
def __init__(self, logger):
self._logger = logger # has-a, not is-a
def run(self, data):
self._logger.log(f"Running pipeline on {len(data)} items")
return [x * 2 for x in data]
Pipeline(logger=CSVLogger()).run([1, 2, 3])
Pipeline(logger=JSONLogger()).run([4, 5, 6])
# Because the collaborator is injected, testing needs no subclassing at all
class StubLogger:
def __init__(self):
self.messages = []
def log(self, msg):
self.messages.append(msg)
stub = StubLogger()
Pipeline(logger=stub).run([7, 8])
print("Captured by stub:", stub.messages)
CSV: [PREFIX] hello
(flush)
CSV: Running pipeline on 3 items
{"msg": "Running pipeline on 3 items"}
Captured by stub: ['Running pipeline on 2 items']
The composed Pipeline never changed across three completely different loggers — CSV, JSON, and a test stub — because it depends on what a logger does, not on what a logger is. That is the flexibility inheritance can’t easily give you: swapping behaviour without touching the class that uses it, and testing it by injecting a stub instead of subclassing.
When inheritance is the right call
It still earns its place when:
- the subclass is a genuine subtype — Liskov holds, and a
Subworks anywhere aBaseis expected; - you want polymorphic dispatch enforced by an abstract base class;
- the hierarchy stays shallow, one or two levels. Deep trees almost always hide a composition waiting to happen.
In practice the two combine well: an ABC defines the what (the interface), and composition wires the how (the implementation) together at runtime.