datarekha
Python Medium Asked at GoogleAsked at MetaAsked at StripeAsked at AirbnbAsked at Microsoft

When should you use composition instead of inheritance in Python, and what are the design signals for each?

The short answer

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 Sub works anywhere a Base is 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.

Learn it properly Inheritance vs Composition

Keep practising

All Python questions

Explore further

Skip to content