Every OOP tutorial teaches inheritance. Few teach when not to use it.

The Inheritance Trap

It starts innocently:

class Animal:
    def speak(self):
        pass
 
class Dog(Animal):
    def speak(self):
        return "Woof"
 
class Cat(Animal):
    def speak(self):
        return "Meow"

Clean hierarchy. Then requirements change:

class RobotDog(Dog):  # Can't bark, but is it a Dog?
    def speak(self):
        return "Beep"
 
class FlyingCat(Cat):  # Cats don't fly...
    def fly(self):
        return "Whoosh"

Now you have RobotDog that inherits Dog behaviors it doesn't need, and FlyingCat mixing concerns. The hierarchy becomes a constraint, not a help.

What Composition Looks Like

from dataclasses import dataclass
from typing import Protocol
 
class Speaker(Protocol):
    def speak(self) -> str: ...
 
class Flyer(Protocol):
    def fly(self) -> str: ...
 
@dataclass
class Barker:
    def speak(self) -> str:
        return "Woof"
 
@dataclass
class Meower:
    def speak(self) -> str:
        return "Meow"
 
@dataclass
class Beeper:
    def speak(self) -> str:
        return "Beep"
 
@dataclass
class Wings:
    def fly(self) -> str:
        return "Whoosh"
 
@dataclass
class Dog:
    voice: Speaker = field(default_factory=Barker)
    
    def speak(self) -> str:
        return self.voice.speak()
 
@dataclass
class RobotDog:
    voice: Speaker = field(default_factory=Beeper)
    
    def speak(self) -> str:
        return self.voice.speak()
 
@dataclass  
class FlyingCat:
    voice: Speaker = field(default_factory=Meower)
    wings: Flyer = field(default_factory=Wings)
    
    def speak(self) -> str:
        return self.voice.speak()
    
    def fly(self) -> str:
        return self.wings.fly()

Mix and match behaviors without hierarchies.

When Inheritance Works

Inheritance isn't always wrong. It works when:

True "is-a" relationships exist:

class HTTPError(Exception):
    pass
 
class NotFoundError(HTTPError):
    status_code = 404

Exceptions are a classic case. A NotFoundError genuinely is an HTTPError.

You're extending a framework:

class MyTestCase(unittest.TestCase):
    def setUp(self):
        self.client = TestClient()

The framework expects inheritance. Fight it and you'll suffer.

The hierarchy is stable: If the base class hasn't changed in years and won't change, inheritance is safe. Standard library classes often qualify.

When Composition Wins

Behaviors vary independently:

# Bad: explosion of subclasses
class EmailNotifier(Notifier): ...
class SMSNotifier(Notifier): ...
class EmailAndSMSNotifier(Notifier): ...  # Uh oh
 
# Good: compose behaviors
@dataclass
class NotificationService:
    channels: list[NotificationChannel]
    
    def notify(self, message: str):
        for channel in self.channels:
            channel.send(message)

You need runtime flexibility:

# Swap implementations without subclassing
service = PaymentService(
    processor=StripeProcessor() if prod else MockProcessor()
)

Testing is important: Composed dependencies are trivially mockable. Inherited behavior often isn't.

The Practical Test

Before creating a subclass, ask:

  1. Is this a true "is-a" relationship?
  2. Will the parent class change?
  3. Am I inheriting behavior I don't want?
  4. Could I achieve this with a parameter instead?

If any answer is concerning, try composition first.

Python-Specific Patterns

Protocols over abstract base classes:

from typing import Protocol
 
class Repository(Protocol):
    def save(self, item: Item) -> None: ...
    def get(self, id: str) -> Item | None: ...
 
# Any class with these methods works—no inheritance needed

Dependency injection:

@dataclass
class UserService:
    repo: Repository
    mailer: Mailer
    
    def create_user(self, data: dict) -> User:
        user = User(**data)
        self.repo.save(user)
        self.mailer.send_welcome(user)
        return user

Mixins when you must:

class TimestampMixin:
    created_at: datetime
    updated_at: datetime
 
class User(TimestampMixin, BaseModel):
    name: str

Mixins are inheritance, but narrow and focused. Use sparingly.

The Mental Shift

Inheritance: "What is this thing?" Composition: "What does this thing do?"

Think in behaviors, not taxonomies. Your code will be more flexible and easier to test.

React to this post: