Composition and inheritance are two ways to combine a class with other behaviour, and choosing wrongly is one of the more expensive mistakes in object-oriented design. Inheritance says "this class is a that class". Composition says "this class has a that class". The second is far more flexible, and in most real code it is the one you want.
Both produce working code. The difference shows up later, when requirements change: inheritance ties you to a parent you cannot easily swap, while composition holds a collaborator you can replace at any time — even at runtime.
How do you tell is-a from has-a?
Finish the sentence "a Car is a ..." with something for which the substitution makes sense. "A car is a vehicle" passes, so inheritance from Vehicle is reasonable. "A car is an engine" does not pass, so a car should have an engine, stored as an attribute.
The same test works for the classic mistakes. A FileWriter is not a Log, so it should not inherit from one. A SQLAlchemy session is not a repository, even though it can fetch things. A User is not a dict just because both hold key-value data.
class Vehicle:
def __init__(self, wheels):
self.wheels = wheels
def describe(self):
return f"a vehicle with {self.wheels} wheels"
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"engine running at {self.horsepower}hp"
class Car(Vehicle):
def __init__(self, engine):
super().__init__(wheels=4)
self.engine = engine
def describe(self):
return super().describe() + " and " + self.engine.start()
print(Car(Engine(120)).describe())
Here Car inherits from Vehicle because the substitution holds, and holds an Engine as an attribute because the substitution does not. That is the shape you want: inheritance for what a thing genuinely is, composition for what it uses.
What does a composition-based version look like?
The same car, written with composition, takes its engine as a constructor argument. The car no longer knows how the engine works; it only knows the interface it expects.
class Engine:
def __init__(self, label):
self.label = label
def start(self):
return f"{self.label} engine running"
class Car:
def __init__(self, engine):
self.engine = engine
def drive(self):
return self.engine.start() + ", wheels turning"
print(Car(Engine("petrol")).drive())
Notice what changed. The Car class now has a single responsibility: it orchestrates the parts it holds. The engine is a separate object with its own tests. If you later need an electric engine with a different interface, you write one more class and pass it in — the Car code does not change at all.
Why is composition easier to change, and easier to test?
Because the collaborator is a value you pass in, not a fixed parent baked into the class. Swapping it is an assignment.
class Engine:
def __init__(self, label):
self.label = label
def start(self):
return f"{self.label} engine running"
class ElectricMotor:
def __init__(self, kilowatts):
self.kilowatts = kilowatts
def start(self):
return f"electric motor at {self.kilowatts}kW"
class Car:
def __init__(self, engine):
self.engine = engine
def drive(self):
return self.engine.start() + ", wheels turning"
my_car = Car(Engine("petrol"))
print(my_car.drive())
my_car.engine = ElectricMotor(100)
print(my_car.drive())
print("the class of the car never changed:", type(my_car).__name__)
Try to do the same with inheritance. You would need a new class, ElectricCar(Vehicle), that duplicates or overrides Car's behaviour, and any code that checks isinstance(car, Car) would now be wrong. With composition the type of the car did not change at all — only what is inside it.
That property is what makes composition the better default whenever a second implementation might be needed: logging versus real output, a real database versus an in-memory one, a real payment gateway versus a fake one in tests.
The testing benefit follows from the same design. Because the dependency is explicit and injectable, a test can supply a stub that records what it was asked to do, without patching anything globally or reaching into the class's internals.
class RecordingSender:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message)
return "sent"
class Notifier:
def __init__(self, sender):
self.sender = sender
def notify(self, message):
return f"{self.sender.send(message)}: {message}"
stub = RecordingSender()
notifier = Notifier(stub)
print(notifier.notify("disk almost full"))
print("the stub recorded:", stub.sent)
With inheritance, testing usually means subclassing the class under test and overriding the part you do not want to run — which is awkward when the part is buried several levels deep, and impossible when it is created inside a method rather than passed in. Composition turns that difficulty into a constructor argument.
This is the strongest practical argument for composition. Tests that need no mocking frameworks, no monkey-patching and no global state tend to be tests people actually write.
When is inheritance still the right choice?
Three situations justify it. First, mixins — small classes that add behaviour to a family of classes without carrying state, exactly as the previous lesson built them. Second, protocols and abstract base classes, where you want to state a contract that several unrelated classes must satisfy, usually with abc.ABC and @abstractmethod. Third, genuine is-a taxonomies that will not need a second implementation — a Dog and a Cat that both have a Pet base with shared, stable behaviour.
| Situation | Reach for | Why |
|---|---|---|
| the object has a collaborator | composition | the collaborator can be swapped or stubbed |
| you want a shared base with no state | inheritance (mixin) | it adds behaviour to many classes at once |
| you want to enforce a contract | inheritance (ABC) | the base declares what must exist |
| the object really is a kind of something | inheritance | the is-a substitution reads naturally |
Note that in every inheritance case above, the base class either carries no state or states a contract. Those are the situations where inheritance stays safe. A base class carrying mutable attributes that subclasses are expected to share is the fragile case, and composition sidesteps it entirely.
Is there a third option?
Often there is: neither. Python does not require you to declare that a class implements anything. If your code only calls engine.start(), then anything with a start() method will do, and you never have to inherit from a common base to satisfy the type checker.
def start_anything(engine):
return engine.start()
class PetrolEngine:
def start(self):
return "petrol engine running"
class ElectricMotor:
def start(self):
return "electric motor running"
print(start_anything(PetrolEngine()))
print(start_anything(ElectricMotor()))
This is duck typing — the type of an object is determined by what it can do, not by what it declares. It pairs naturally with composition: you hold a collaborator, and the only requirement is that it responds to the methods you call.
The downside is that failures happen later, at the point of use, rather than at construction time. An abstract base class trades some of that flexibility for an earlier, clearer error. Many codebases use both: duck typing at the edges, abstract bases at the core.
The decision rule
- Inheritance models is-a. Composition models has-a. Misidentifying the relationship is the root mistake.
- Composition holds a collaborator you can swap at runtime; inheritance bakes the parent in permanently.
- Injecting dependencies makes them trivial to stub in tests.
- Inheritance earns its place for mixins, abstract base classes and stable taxonomies.
- When in doubt, prefer composition — and let duck typing handle the rest.
Try it yourself: refactor one inheritance into composition
Take a class of your own that inherits from another, and list its inherited methods one by one. For each one, ask whether the subclass genuinely is that kind of thing, or whether it merely uses the parent's capability. Most of the time the answer splits the inherited methods into two groups.
Move the second group into a collaborator passed to the constructor, and delete the inheritance. Write a test that supplies a recording stub in place of the real collaborator and asserts on what was called. Finally, write a second implementation of the collaborator — a fake one for tests and a real one for production — and switch between them at the call site without changing the class that uses it.