Every Python developer reaches a crossroads moment. You have two classes that share behavior, and you need to decide: should one inherit from the other, or should one contain the other? This decision — composition vs. inheritance — is one of the most consequential architectural choices in object-oriented programming, and getting it wrong can haunt a codebase for years.
The guidance from the software engineering community has been remarkably consistent for three decades. In their landmark 1994 book Design Patterns: Elements of Reusable Object-Oriented Software, Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides — collectively known as the “Gang of Four” — wrote what has become one of the most cited principles in software design: “Favor object composition over class inheritance.” That sentence, appearing on page 20 of the original text, launched decades of debate, refinement, and (occasionally) dogma.
But “favor” does not mean “always use.” Understanding why the Gang of Four made this recommendation, how Python’s unique features change the calculus, and when inheritance is still the right tool — that’s what separates developers who parrot advice from developers who make good design decisions.
Inheritance: The “Is-A” Relationship
Inheritance is the mechanism that lets a class derive behavior and structure from a parent class. When you write class Dog(Animal), you are declaring that a Dog is a specialized kind of Animal. The child class inherits every method and attribute from the parent, and can override or extend them as needed.
class Animal:
def __init__(self, name: str, sound: str):
self.name = name
self.sound = sound
def speak(self) -> str:
return f"{self.name} says {self.sound}!"
def describe(self) -> str:
return f"{self.name} is an animal."
class Dog(Animal):
def __init__(self, name: str):
super().__init__(name, sound="Woof")
def fetch(self, item: str) -> str:
return f"{self.name} fetches the {item}!"
Here, Dog inherits speak() and describe() from Animal without rewriting them. It overrides __init__ to hard-code the sound and adds a new method, fetch(). This is inheritance doing what it does best: expressing a genuine specialization relationship with minimal repeated code.
The super() call is critical here. It delegates initialization to the parent class, maintaining the chain of responsibility that Python’s Method Resolution Order (MRO) depends on. Python calculates MRO using the C3 linearization algorithm, which was formalized during the Python 2.3 development cycle. The definitive explanation of how this algorithm works appeared in Michele Simionato’s essay “The Python 2.3 Method Resolution Order,” published alongside the Python 2.3 release documentation in 2003. Understanding MRO becomes essential when multiple inheritance enters the picture.
When Inheritance Works Well
Inheritance shines when the “is-a” relationship is genuine and stable. The Python standard library is full of good examples. The collections module provides OrderedDict, which inherits from dict because an ordered dictionary truly is a dictionary with additional ordering guarantees. The io module’s BufferedReader inherits from BufferedIOBase because a buffered reader genuinely is a type of buffered I/O stream.
The relationship holds up because the Liskov Substitution Principle (LSP) is satisfied: anywhere you can use the parent, you can use the child without breaking expectations. Barbara Liskov and Jeannette Wing formalized this in their 1994 paper “A Behavioral Notion of Subtyping,” published in ACM Transactions on Programming Languages and Systems. Their core argument was that objects of a subtype should behave the same as those of the supertype from the perspective of any code using supertype objects.
When Inheritance Falls Apart
The problems start when the “is-a” relationship is forced, approximate, or unstable. Consider a classic example that looks reasonable at first:
class Employee:
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
def calculate_pay(self) -> float:
return self.salary / 24 # semi-monthly
class Manager(Employee):
def __init__(self, name: str, salary: float, bonus: float):
super().__init__(name, salary)
self.bonus = bonus
def calculate_pay(self) -> float:
return super().calculate_pay() + (self.bonus / 12)
class Intern(Employee):
def __init__(self, name: str, stipend: float):
super().__init__(name, salary=stipend)
def calculate_pay(self) -> float:
return self.salary / 12 # monthly stipend
This seems fine until business requirements evolve. What happens when an employee is both a manager and a contractor? Or when a manager transitions to a part-time role? The inheritance hierarchy becomes a straitjacket. You either resort to multiple inheritance (with all its diamond-problem complexity) or start duplicating code across branches of the hierarchy.
This is what the Gang of Four warned about in Design Patterns when they described how inheritance leads to a “proliferation of classes” and “an explosion of subclasses to support every combination.” Their solution was composition.
Composition: The “Has-A” Relationship
Composition models relationships by having one class contain instances of other classes rather than inheriting from them. A Car does not inherit from Engine; a car has an engine. The containing class delegates behavior to its components.
Let’s rebuild the employee example using composition:
from typing import Protocol
class PayStrategy(Protocol):
"""Defines the interface for any pay calculation strategy."""
def calculate(self, base_amount: float) -> float: ...
class SemiMonthlyPay:
def calculate(self, base_amount: float) -> float:
return base_amount / 24
class MonthlyStipend:
def calculate(self, base_amount: float) -> float:
return base_amount / 12
class BonusPay:
def __init__(self, base_strategy: PayStrategy, annual_bonus: float):
self.base_strategy = base_strategy
self.annual_bonus = annual_bonus
def calculate(self, base_amount: float) -> float:
return self.base_strategy.calculate(base_amount) + (self.annual_bonus / 12)
class Employee:
def __init__(self, name: str, base_amount: float, pay_strategy: PayStrategy):
self.name = name
self.base_amount = base_amount
self.pay_strategy = pay_strategy
def calculate_pay(self) -> float:
return self.pay_strategy.calculate(self.base_amount)
Now creating different kinds of employees is a matter of injecting the right strategy:
regular = Employee("Alice", 96_000, SemiMonthlyPay())
intern = Employee("Bob", 24_000, MonthlyStipend())
manager = Employee(
"Carol",
120_000,
BonusPay(SemiMonthlyPay(), annual_bonus=20_000),
)
Notice what happened. The Employee class no longer needs to know anything about the specifics of pay calculation. The pay strategy can be swapped at runtime. Adding a new pay type requires writing a new class, not modifying existing ones. Testing each strategy in isolation is trivial.
This is the Strategy pattern from the Gang of Four book, and it is one of the clearest illustrations of why composition provides superior flexibility. Python’s standard library logging module is one of the best real-world examples of this principle in practice: the Logger class does not implement filtering or output itself — it maintains a list of filters and a list of handlers. Each log message passes through every filter, and accepted messages are dispatched to every handler. This design, formally introduced through PEP 282, makes it possible to reconfigure logging behavior entirely at runtime without touching any class definitions.
The PEPs That Shape This Decision
Python’s evolution through PEPs has gradually made composition easier and more Pythonic while also refining the role of inheritance. Several PEPs are directly relevant to the composition vs. inheritance question.
PEP 3119 — Introducing Abstract Base Classes (Python 3.0)
Authored by Guido van Rossum and Talin, PEP 3119 introduced the abc module and formalized Abstract Base Classes in Python. The PEP’s rationale acknowledged a fundamental tension in Python’s duck-typing philosophy: how do you verify that an object supports a particular interface without relying on fragile hasattr() checks or rigid inheritance requirements?
ABCs provide a middle ground. They let you define a required interface that subclasses must implement, while also supporting “virtual subclasses” that can be registered without actual inheritance. This is significant for the composition vs. inheritance debate because it means you can use isinstance() checks against an ABC even when the class was never part of the inheritance hierarchy.
from abc import ABC, abstractmethod
class Renderable(ABC):
@abstractmethod
def render(self) -> str:
...
class HtmlWidget(Renderable):
def render(self) -> str:
return "<div>Hello</div>"
The companion PEP 3141 (“A Type Hierarchy for Numbers”), authored by Jeffrey Yasskin, extended this approach to define ABCs for numeric types: Number, Complex, Real, Rational, and Integral. This hierarchy demonstrates a case where inheritance is appropriate: real numbers genuinely are complex numbers (with an imaginary component of zero), and integers genuinely are rational numbers. The mathematical “is-a” relationship holds perfectly.
PEP 544 — Protocols: Structural Subtyping (Python 3.8)
Perhaps the most important PEP for composition-oriented design is PEP 544, created in 2017 and authored by Ivan Levkivskyi, Jukka Lehtosalo, and Łukasz Langa. Accepted for Python 3.8 in May 2019, this PEP introduced Protocol classes, which enable what the PEP calls “static duck typing” — type checking based on what methods an object has rather than what it inherits from.
The PEP itself states that structural subtyping “is natural for Python programmers since it matches the runtime semantics of duck typing: an object that has certain properties is treated independently of its actual runtime class.”
This is a game-changer for composition. Before PEP 544, if you wanted type safety with composed objects, you had two options: use ABCs (which require inheritance) or abandon type checking. Protocols let you define interfaces that classes satisfy implicitly, just by having the right methods:
from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str: ...
class UserProfile:
"""No inheritance from Serializable needed."""
def __init__(self, username: str, email: str):
self.username = username
self.email = email
def to_json(self) -> str:
import json
return json.dumps({"username": self.username, "email": self.email})
def save_to_file(obj: Serializable, path: str) -> None:
"""Accepts ANY object that has a to_json() method."""
with open(path, "w") as f:
f.write(obj.to_json())
# Works perfectly -- UserProfile satisfies the Protocol structurally.
save_to_file(UserProfile("alice", "[email protected]"), "profile.json")
UserProfile never imports Serializable, never inherits from it, and never even knows it exists. Yet a static type checker like mypy will verify that the call to save_to_file is valid because UserProfile structurally matches the Serializable protocol. This is composition-friendly design at its best.
To enable runtime isinstance() checks against a Protocol (not just static analysis), decorate it with @runtime_checkable from typing. Without this decorator, Protocol classes are for static type checkers only and will raise a TypeError if used with isinstance() at runtime.
PEP 282 — A Logging System (Python 2.3)
PEP 282, authored by Vinay Sajip and Trent Mick, introduced Python’s logging module. While not explicitly about composition vs. inheritance, the logging module’s architecture is one of the clearest real-world examples of composition over inheritance in the standard library.
Rather than using a class hierarchy where FileLogger inherits from Logger and SocketLogger inherits from Logger, the module separates concerns into composable components: Logger objects hold references to Handler objects and Filter objects. You configure logging by assembling these components, not by subclassing. Want to log to both a file and a socket? Attach both handlers. Want to filter out debug messages? Attach a filter. No inheritance required.
PEP 484 — Type Hints (Python 3.5)
PEP 484, authored by Guido van Rossum, Jukka Lehtosalo, and Łukasz Langa, introduced type hints to Python. Created in 2014 and accepted for Python 3.5, while type hints work with both inheritance and composition, they pushed the ecosystem toward explicit interfaces — which naturally favors composition. When you annotate a function parameter with a type, you are declaring what interface that parameter must satisfy. Whether the argument satisfies that interface through inheritance or through structural typing (via PEP 544 Protocols) is an implementation detail the caller doesn’t need to care about.
Python’s Duck Typing: A Third Path
One thing that makes the composition vs. inheritance discussion different in Python compared to languages like Java or C++ is Python’s duck typing philosophy. In many situations, you don’t need either inheritance or formal composition patterns. If it walks like a duck and quacks like a duck, Python treats it like a duck.
class Duck:
def quack(self):
return "Quack!"
class Person:
def quack(self):
return "I'm quacking like a duck!"
def make_it_quack(thing):
print(thing.quack())
make_it_quack(Duck()) # Quack!
make_it_quack(Person()) # I'm quacking like a duck!
No inheritance. No explicit composition. No protocols. Just objects that happen to have the same method. Python’s dynamism means that the “interface” is implicit in how the object is used, not in what it inherits from or formally declares.
This is powerful but comes with a trade-off: there is no compile-time safety net. If you pass an object that lacks the expected method, you get a runtime AttributeError. PEP 544’s Protocols bridge this gap by letting you get the flexibility of duck typing with the safety of static analysis.
A Real-World Example: Building a Notification System
Let’s put everything together with a realistic example that shows why composition produces more maintainable code than inheritance.
The inheritance approach might start like this:
class Notifier:
def send(self, message: str, recipient: str) -> None:
raise NotImplementedError
class EmailNotifier(Notifier):
def send(self, message: str, recipient: str) -> None:
print(f"Emailing {recipient}: {message}")
class SMSNotifier(Notifier):
def send(self, message: str, recipient: str) -> None:
print(f"Texting {recipient}: {message}")
This works until you need to send notifications through multiple channels, add retry logic, or log every notification. With inheritance, you end up with LoggingEmailNotifier, RetryingSMSNotifier, LoggingRetryingEmailNotifier — the combinatorial explosion the Gang of Four warned about.
Here is the composition-based alternative:
from typing import Protocol
class MessageSender(Protocol):
def send(self, message: str, recipient: str) -> None: ...
class EmailSender:
def send(self, message: str, recipient: str) -> None:
print(f"Emailing {recipient}: {message}")
class SMSSender:
def send(self, message: str, recipient: str) -> None:
print(f"Texting {recipient}: {message}")
class RetryingSender:
def __init__(self, sender: MessageSender, max_retries: int = 3):
self._sender = sender
self._max_retries = max_retries
def send(self, message: str, recipient: str) -> None:
for attempt in range(1, self._max_retries + 1):
try:
self._sender.send(message, recipient)
return
except Exception:
if attempt == self._max_retries:
raise
print(f"Retry {attempt}/{self._max_retries}...")
class LoggingSender:
def __init__(self, sender: MessageSender):
self._sender = sender
def send(self, message: str, recipient: str) -> None:
print(f"[LOG] Sending to {recipient}")
self._sender.send(message, recipient)
print(f"[LOG] Sent successfully")
class MultiChannelNotifier:
def __init__(self, senders: list[MessageSender]):
self._senders = senders
def notify(self, message: str, recipient: str) -> None:
for sender in self._senders:
sender.send(message, recipient)
# Assemble behavior through composition:
notifier = MultiChannelNotifier([
LoggingSender(RetryingSender(EmailSender())),
LoggingSender(SMSSender()),
])
notifier.notify("Server is down!", "[email protected]")
Each component does one thing. RetryingSender wraps any sender with retry logic. LoggingSender wraps any sender with logging. MultiChannelNotifier dispatches to multiple senders. You can combine them in any order, swap implementations, and test each piece independently. No class hierarchy to maintain, no diamond problems, no fragile base class issues.
Notice that RetryingSender and LoggingSender both accept and return a MessageSender. This is the Decorator pattern — wrapping an object to extend its behavior without subclassing. It’s one of the most useful patterns in Python and becomes trivial to implement once you embrace composition and Protocols.
The Decision Framework
Rather than a rigid rule, treat this as a checklist for the next time you’re deciding between the two approaches.
Use inheritance when the relationship is a genuine, stable “is-a” specialization. A dict subclass that adds ordering is a dictionary. A unittest.TestCase subclass is a test case. The Liskov Substitution Principle is easily satisfied, and the parent class is unlikely to change in ways that break the child.
Use composition when you need to combine behaviors from multiple sources, when the relationship is “has-a” or “uses-a,” when you want to swap implementations at runtime, or when testing individual components in isolation matters. This covers the vast majority of real-world design decisions.
Use Protocols (PEP 544) when you want the type safety of interfaces without the coupling of inheritance. Protocols are especially powerful when different parts of your codebase need to work together without importing each other’s concrete classes.
Use ABCs (PEP 3119) when you need to enforce that subclasses implement specific methods and you want a clear, framework-level contract. ABCs are inheritance, but they’re inheritance used judiciously — to define an interface rather than to share implementation.
The Takeaway
The Gang of Four’s advice from 1994 has aged remarkably well, and Python’s evolution has only made composition easier to implement. PEP 544’s Protocols eliminate the last major argument for reaching toward inheritance when you don’t need it: the desire for type-safe interfaces. PEP 3119’s ABCs give you enforcement when you want it. And Python’s native duck typing means you often don’t need either formal mechanism at all.
The goal is not to avoid inheritance entirely. It is to reach for it deliberately, when the “is-a” relationship is real and the Liskov Substitution Principle holds naturally. For everything else — and that turns out to be most things — composition produces code that is easier to test, easier to extend, easier to understand, and easier to delete when requirements change.
Think about that last point for a moment: easier to delete. In a composition-based design, removing a feature means removing a component class and the lines that wire it in. In an inheritance-based design, removing a feature can mean untangling behavior spread across multiple levels of a class hierarchy. As any veteran of a large Python codebase will tell you, the ability to safely delete code is one of the most underappreciated signs of good architecture.
Build small, focused classes. Compose them together. Let inheritance serve the narrow role it was always meant for: expressing genuine specialization. Your future self — and everyone else who reads your code — will thank you.