The Gang of Four's Design Patterns (1994) buried one rule in the introduction so deep that most readers miss it, even though it's the most-cited line in the book: favor object composition over class inheritance. Thirty years later it's still the right default, and Python's culture has internalized it more than Java's or C++'s. The reason is the math: inheritance handles one axis of variation cleanly, but most real classes vary along multiple axes (a game character has a movement style AND a weapon AND a sensor), and combining them with inheritance produces a combinatorial explosion of subclasses. Composition handles the same problem with linear growth. This entry walks through the is-a/has-a test, the cases where inheritance still wins, and the modern Python tools (Protocol classes, dataclass field injection) that make composition cleaner than ever. One of 10 explainers in our Python OOP complete guide.

Key Takeaways

  • The test: "is-a" suggests inheritance, "has-a" suggests composition. When in doubt, go composition.
  • Combinatorial explosion: N axes of variation produce 2N subclasses with inheritance, but only N + M components with composition.
  • Inheritance still wins for: framework hooks (Django Model, pytest fixtures), genuine specialization, and abstract base class contracts.
  • Composition wins for: swappable behavior, cross-cutting concerns, runtime configuration, loose coupling.
  • Modern Python tools: Protocol classes (PEP 544) give structural typing without inheritance; dataclass field injection makes composition concise.
Same problem, two architectures: inheritance explodes, composition scales linearly Inheritance explodes, composition stays flat 3 MOVEMENT TYPES · 2 WEAPON TYPES · ONE PROBLEM, TWO ARCHITECTURES INHERITANCE: COMBINATORIAL EXPLOSION Character Walker Flyer Swimmer Walker Sword Walker Bow Flyer Sword Flyer Bow Swimmer Sword Swimmer Bow + 2 more... PROBLEMS · 3 movements × 2 weapons = 6 leaves · Add a third axis (armor) → 12 leaves · Add a fourth → 24, then 48... Scales as 2Ν: exponential explosion. · Diamond/MRO complexity grows · Fragile base class problem · Capabilities locked in at class-def time COMPOSITION: LINEAR GROWTH Character holds 3 components HAS-A HAS-A HAS-A Mover Walk() Fly() Swim() Weapon Sword Bow (none) Sensor Sight Sound (none) BENEFITS · 3 + 2 + 3 = 8 small classes total · Add a 4th axis → +N classes, not ×N · Swap any component at runtime Scales as ΣNk: linear growth. · No MRO, no diamond · Components testable in isolation · Protocol classes give type safety
The same problem — characters that vary in movement and weapon — modeled as inheritance (left) and composition (right).

The is-a vs has-a Test

The traditional decision rule, restated in plain English:
  • is-a → inheritance candidate. A Dog IS A Animal; an HTTPException IS A BaseException.
  • has-a → composition candidate. A Car HAS AN Engine; a UserProfile HAS AN Avatar.
The test isn't airtight. A Stack HAS A list AND a Stack arguably IS A list with restricted operations. Both interpretations have their day in introductory texts. Brandon Rhodes' Python Design Patterns guide argues persuasively that the Stack case is better as composition: a Stack HOLDS a list and delegates push/pop to it, so users can't accidentally call insert at an arbitrary index. The strict relationship is preserved by what the Stack doesn't expose.

When the is-a/has-a test feels ambiguous, the safer default is composition because it preserves more options later.

The Combinatorial Explosion Problem

The Gang of Four's strongest argument for composition is mathematical. Suppose your game has Characters that vary along two axes:
  • Movement: Walker, Flyer, Swimmer (3 variants)
  • Weapon: Sword, Bow (2 variants)
With inheritance you need a leaf class for each combination: WalkerWithSword, WalkerWithBow, FlyerWithSword, FlyerWithBow, SwimmerWithSword, SwimmerWithBow. That's 3 × 2 = 6 leaves. Add a third axis (armor type, 2 variants) and you're at 12. Add a fourth (sensor type, 3 variants) and you're at 36. The class count grows as the product of variant counts — what the GoF called the proliferation of subclasses.

Composition handles the same problem with a sum, not a product. You write 3 mover classes, 2 weapon classes, 2 armor classes, 3 sensor classes — 10 small focused classes total — and assemble them at construction time. The visual above contrasts the two shapes directly.Composition vs Inheritance in Python: When to Favor Each - 1

Composition in Python: The Minimal Version

Composition in Python is simpler than it sounds. You hold the component as an instance attribute and delegate calls to it.
class WalkMover:
    def move(self, character):
        print(f"{character.name} walks east")

class FlyMover:
    def move(self, character):
        print(f"{character.name} flies overhead")

class Sword:
    def attack(self, character, target):
        print(f"{character.name} swings sword at {target}")

class Character:
    def __init__(self, name, mover, weapon):
        self.name = name
        self.mover = mover
        self.weapon = weapon

    def move(self):
        self.mover.move(self)

    def attack(self, target):
        self.weapon.attack(self, target)

knight = Character("Knight", WalkMover(), Sword())
knight.move()              # Knight walks east
knight.attack("dragon")    # Knight swings sword at dragon

# Hot-swap at runtime — impossible with pure inheritance
knight.mover = FlyMover()
knight.move()              # Knight flies overhead
The Character knows nothing about HOW movement happens; it just delegates to whatever Mover is plugged in. Swap the Mover on the fly and behavior changes immediately. This runtime swappability is what composition gives you and inheritance can't.

Same Problem with Inheritance: Why It Hurts

class Character:
    def __init__(self, name):
        self.name = name

class WalkerCharacter(Character):
    def move(self): print(f"{self.name} walks")

class FlyerCharacter(Character):
    def move(self): print(f"{self.name} flies")

class WalkerWithSword(WalkerCharacter):
    def attack(self, target): print(f"{self.name} swings sword")

class FlyerWithSword(FlyerCharacter):
    def attack(self, target): print(f"{self.name} swings sword")

class WalkerWithBow(WalkerCharacter):
    def attack(self, target): print(f"{self.name} shoots arrow")

# ... and 3 more leaf classes
The attack method for Sword is duplicated across WalkerWithSword and FlyerWithSword. You can solve THAT specific duplication with mixins, but mixins introduce MRO complexity and the diamond problem covered in the super and MRO guide. Composition sidesteps the whole category.

The Fragile Base Class Problem

Even when inheritance fits, deep hierarchies create a long-term liability. The classic example: a subclass calls a parent method that internally calls another method. A later refactor changes that internal call. The subclass breaks silently because it depended on an implementation detail of the parent.
# Parent class
class Logger:
    def log(self, msg):
        self._format(msg)
        self._write(msg)

    def _format(self, msg): ...
    def _write(self, msg): print(msg)

# Subclass relies on _format being called
class TimestampedLogger(Logger):
    def _format(self, msg):
        return f"[2026-06-19] {msg}"

# Later, parent maintainer "optimizes":
class Logger:
    def log(self, msg):
        self._write(msg)   # Skipped _format() for speed
# Subclass silently stops adding timestamps. No test catches it.
This is the fragile base class problem. The Gang of Four cited it as a key reason to prefer composition. The deeper your inheritance tree, the more places this kind of breakage can hide. Composition makes the relationship explicit (a method call through a stored attribute), so refactors are loud, not silent.

When Inheritance Still Wins

The principle isn't "never inherit." Three cases where inheritance is the better tool:

1. Framework hooks

Django's Model, FastAPI's BaseModel, Flask's View, pytest's collectors — these frameworks expect you to subclass and override specific methods. The framework's machinery walks your class hierarchy looking for hooks. Composition can't replace this; the framework is designed around inheritance.
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()

    class Meta:
        ordering = ["-id"]
Don't fight the framework. Inherit when the framework says inherit.

2. Genuine is-a specialization

When a subtype is genuinely a more specific version of the parent and behaves correctly wherever the parent would, inheritance fits. Python's exception hierarchy is the textbook example: HTTPException IS A Exception, and the except machinery depends on that hierarchy. Don't model exceptions with composition; the catch behavior assumes inheritance.

3. Abstract base classes that define a contract

An abc.ABC with @abstractmethod declares a contract; concrete subclasses promise to implement it. This works as a typing tool (cleaner than ducktyping) and as a runtime check (instantiating an unimplemented ABC raises). Covered in the ABC and abstractmethod guide.

Modern Python: Protocols Remove the Inheritance Requirement

Before Python 3.8 you needed inheritance from abc.ABC to formally express "implements this interface." PEP 544 added typing.Protocol for structural subtyping: any class with the right methods satisfies the protocol, no inheritance needed.
from typing import Protocol

class Mover(Protocol):
    def move(self, character) -> None: ...

class WalkMover:
    def move(self, character) -> None:
        print(f"{character.name} walks")

class FlyMover:
    def move(self, character) -> None:
        print(f"{character.name} flies")

class Character:
    def __init__(self, name: str, mover: Mover):
        self.name = name
        self.mover = mover

# WalkMover and FlyMover don't inherit from Mover —
# type checker accepts them because they have the right shape.
knight = Character("Knight", WalkMover())
Protocols pair perfectly with composition: the component types the Character holds are duck-typed against a Protocol, so no class hierarchy is needed. Type checkers (mypy, pyright) validate the shape at static-analysis time. This is the modern Python equivalent of "program to an interface, not an implementation" — another GoF principle.

Composition + Dataclasses: The Concise Pattern

@dataclass makes composition almost free. The component types become typed fields, and the auto-generated __init__ wires them up.
from dataclasses import dataclass
from typing import Protocol

class Mover(Protocol):
    def move(self, character) -> None: ...

class Weapon(Protocol):
    def attack(self, character, target) -> None: ...

@dataclass
class Character:
    name: str
    mover: Mover
    weapon: Weapon

    def move(self): self.mover.move(self)
    def attack(self, t): self.weapon.attack(self, t)

knight = Character(name="Knight", mover=WalkMover(), weapon=Sword())
Twelve lines, full composition with type-checked Protocol contracts, swappable at runtime. The verbose Java pattern compressed to its essence.

Decision Rules

SituationChoose
Relationship reads as "X is a kind of Y" in plain EnglishInheritance
Relationship reads as "X uses Y" or "X has a Y"Composition
Framework requires you to subclassInheritance (no choice)
Variation along 2+ orthogonal axesComposition
Need to swap behavior at runtimeComposition
Need a formal contract checkable at type-check timeABC or Protocol
Multiple inheritance with possible diamondComposition (re-examine the design)
Subclass would only override one methodStrategy via composition

Common Mistakes

1. Inheriting just to reuse code

If the subclass doesn't use ALL of the parent's interface, you have a broken is-a. Code reuse is a side effect of inheritance, not its purpose. When you want to share code without an is-a relationship, extract a helper function or hold the helper as a composed component.

2. Deep inheritance trees

Three or more levels of inheritance is a smell. Each level adds coupling and obscures behavior because MRO determines which version of a method runs. Refactor toward composition before the tree gets unmanageable.

3. Mixins as inheritance-flavored composition

Mixins sit between the two paradigms. They work for narrow cases (Django's class-based views chain mixins extensively), but introduce MRO complexity that bites later. Default to plain composition; reach for mixins only when the framework already uses them.

4. Ignoring Protocols when you have Python 3.8+

If your project runs on Python 3.8 or newer, typing.Protocol is almost always cleaner than abc.ABC for new code. ABCs force inheritance; Protocols don't.

Real-World Example: Notification Service

Common production pattern: a notification service that sends messages via email, SMS, or Slack. Inheritance pulls you toward EmailNotifier, SMSNotifier, SlackNotifier — siblings under a common base. Composition flips the model:
from typing import Protocol
from dataclasses import dataclass

class Transport(Protocol):
    def send(self, recipient: str, message: str) -> None: ...

class EmailTransport:
    def send(self, recipient, message):
        print(f"EMAIL to {recipient}: {message}")

class SMSTransport:
    def send(self, recipient, message):
        print(f"SMS to {recipient}: {message}")

class SlackTransport:
    def send(self, recipient, message):
        print(f"SLACK to {recipient}: {message}")

@dataclass
class Notifier:
    transport: Transport
    template: str

    def notify(self, user, **kwargs):
        message = self.template.format(**kwargs)
        self.transport.send(user.email, message)

# Configure at runtime — testable, swappable, no inheritance
email_notifier = Notifier(EmailTransport(), "Hi {name}, your order shipped.")
sms_notifier = Notifier(SMSTransport(), "{name}: order confirmed.")
The Notifier stays a single class; transports plug in. Adding a fourth transport (push notifications) adds ONE class. Testing the Notifier in isolation is trivial — pass a fake Transport. With the inheritance approach, you'd need a separate test for each subclass plus a mock framework for the base.

Frequently Asked Questions

What is the difference between composition and inheritance in Python?

Inheritance is an "is-a" relationship: a Dog IS-A Animal, so Dog inherits from Animal and gains its methods automatically. Composition is a "has-a" relationship: a Car HAS-A Engine, so Car holds an Engine instance as an attribute and delegates engine work to it. Inheritance creates tight coupling between parent and subclass; changes to the parent ripple through every descendant. Composition creates loose coupling; you can swap one component for another at runtime without changing class hierarchy. Python supports both, but the Python community follows the Gang of Four guidance: default to composition, reach for inheritance when the relationship is genuinely "is-a" or when you're hooking into a framework that expects it.

Why does the Gang of Four say to favor composition over inheritance?

The 1994 Design Patterns book identifies the core problem: classes often need to vary along several axes at once, and inheritance handles only one axis at a time. If you have 3 movement types and 2 weapon types, inheritance produces 6 subclasses (FlyerWithSword, FlyerWithBow, SwimmerWithSword, SwimmerWithBow, WalkerWithSword, WalkerWithBow). Add a third axis and the count multiplies again — the "combinatorial explosion of subclasses". Composition handles the same problem with N + M classes (3 movements + 2 weapons = 5 classes), and you assemble them at runtime. The principle isn't "never inherit"; it's "default to composition because it scales better with multi-axis variation".

When should I use inheritance instead of composition in Python?

Three cases where inheritance is the right tool: (1) a genuine is-a relationship where the subtype really is a specialization of the parent, like a UserSerializer being a Serializer; (2) framework hooks where the framework expects you to subclass and override specific methods, like Django's Model or pytest's collectors; (3) abstract base classes that define a contract (using ABC + @abstractmethod) and concrete implementations that fulfill it. Outside these cases, composition is the safer default. The honest test: if you'd describe the relationship as "X is a kind of Y" in plain English, inheritance fits; if you'd describe it as "X uses Y" or "X has a Y", go with composition.

What is the fragile base class problem in inheritance?

When subclasses depend on internal implementation details of the parent class, changes to the parent can silently break unrelated subclasses. Python's open class model makes this especially easy to hit: a subclass overrides one method assuming a particular dispatch order, and a refactor to the parent breaks that assumption. The deeper the inheritance hierarchy, the more places the problem can hide. Composition sidesteps it because the relationship between containing class and component is explicit (a method call through a stored attribute), not implicit (an inherited method resolved via MRO). This is one of the strongest practical arguments for composition in long-lived codebases.

How do Python Protocol classes relate to composition?

Protocol classes (added in PEP 544, Python 3.8) enable structural subtyping: any object with the right method signatures is considered to implement the protocol, no inheritance required. This pairs naturally with composition because the components you compose in only need to satisfy a protocol, not inherit from a specific base. A Character class can hold a Mover component, where Mover is a Protocol requiring a single move() method. Any class with a move() method works — no shared base class, no MRO complexity, no inheritance coupling. Protocols give you the type-checking benefits of abstract bases without the runtime inheritance cost.

The Bottom Line: Default to Composition, Use Inheritance Deliberately

Default to composition because it scales linearly with variation and stays loosely coupled. Reach for inheritance when there's a genuine is-a relationship, when a framework requires it, or when an abstract base class formalizes a contract. Use Protocol classes (PEP 544) to get type-checked interfaces without inheritance commitment. Combine composition with @dataclass for the most concise Python expression of the GoF principle. Next up: Abstract Base Classes (ABC) and Protocols: interfaces done right. Or browse the full Python OOP guide.

Practice the Right Design Reflexes

CodeGym's Python track puts composition and inheritance side by side across 800+ tasks, so you build the muscle to recognize which pattern fits before you write the class. AI validators catch over-deep hierarchies; AI mentors explain when a subclass should have been a Protocol instead. First level free; full plan on the pricing page.