Inheritance vs. Composition in Python- When to Use Which
Master Python's inheritance and composition, how they work, when to use each, with clear examples. Elevate your OOP skills effectively.
By Abhay Palakkode · July 2025
Inheritance means creating new classes based on existing ones, where the new class automatically gets all the methods and attributes from the parent class. Composition means building classes by combining other objects, where you create instances of other classes and use them as components inside your class.
If you’ve been writing Python classes, you’ve probably encountered situations where you need to reuse code or share functionality between different classes.
There are two main ways to achieve this in object-oriented programming: inheritance and composition. Understanding these concepts is fundamental to writing clean, maintainable Python code.
The concepts of inheritance and composition come from object-oriented programming principles established in the 1960s. This principle emerged because developers kept running into the same problems – inheritance hierarchies that grew too complex, classes that were tightly coupled, and code that was brittle to change.
1. Understanding Inheritance
Inheritance lets you create a new class based on an existing class. The new class gets all the methods and attributes from the parent class.
Let me show you with a simple example
Let’s create a parent class called Vehicle with basic attributes like brand and model, then create child classes that inherit from it to represent specific vehicle types.
PyInteractive Python
# Base vehicle classclassVehicle:def__init__(self, brand, model):self.brand = brandself.model = modeldefstart(self):print(f"{self.brand}{self.model} is starting")defstop(self):print(f"{self.brand}{self.model} has stopped")
Now let’s create a child class called Car that inherits from the Vehicle parent class, adding car-specific features while keeping all the basic vehicle functionality.
PyInteractive Python
# Car inherits from VehicleclassCar(Vehicle):def__init__(self, brand, model, doors):super().__init__(brand, model)self.doors = doorsdefhonk(self):print(f"{self.brand}{self.model} is honking!")
The Car class inherits everything from Vehicle and adds car-specific functionality. This is inheritance – Car “is-a” Vehicle.
PyInteractive Python
# Test the inheritancemy_car = Car("Toyota", "Camry", 4)my_car.start() # Inherited methodmy_car.honk() # Car-specific methodmy_car.stop() # Inherited method#> Toyota Camry is starting#> Toyota Camry is honking!#> Toyota Camry has stopped
2. Understanding Composition
Composition means building classes by including other objects as attributes. Instead of inheriting, you create instances of other classes inside your class.
Let’s create two separate component classes, Engine for powering vehicles and GPS for navigation. These will be building blocks that we’ll combine later.
PyInteractive Python
# Component classesclassEngine:def__init__(self, horsepower):self.horsepower = horsepowerself.running =Falsedefstart(self):self.running =Trueprint(f"Engine with {self.horsepower}HP started")defstop(self):self.running =Falseprint("Engine stopped")classGPS:defnavigate_to(self, destination):print(f"Navigating to {destination}")
These are our building blocks. Each component class handles one specific responsibility – Engine manages power, GPS handles navigation.
Now let’s create an SmartCar class that uses composition by containing both an Engine object and a GPS object, rather than inheriting from them.
PyInteractive Python
# SmartCar using compositionclassSmartCar:def__init__(self, brand, horsepower):self.brand = brand# Composition: SmartCar HAS an Engine and HAS a GPSself.engine = Engine(horsepower)self.gps = GPS()defstart_journey(self, destination):self.engine.start()self.gps.navigate_to(destination)print(f"{self.brand} is ready to go!")defend_journey(self):self.engine.stop()print(f"{self.brand} journey completed")
Here’s where composition shines. The SmartCar class doesn’t inherit from Engine or GPS, it contains instances of them and delegates specific tasks to specialized objects.
PyInteractive Python
# Test compositionsmart_car = SmartCar("Tesla", 400)smart_car.start_journey("San Francisco")smart_car.end_journey()#> Engine with 400HP started#> Navigating to San Francisco#> Tesla is ready to go!#> Engine stopped#> Tesla journey completed
3. Python’s Object Super Class and Duck Typing
Every class in Python automatically inherits from object. This is why you can use methods like __str__() even when you don’t define them.
Let’s see how these inherited methods work in practice by creating a simple User class and using methods we never defined
PyInteractive Python
# Simple class that doesn't define __str__ or __repr__classUser:def__init__(self, name, email):self.name = nameself.email = email# Create a user and use inherited methodsuser = User("John", "john@example.com")# These methods come from object class, not our User classprint(str(user)) # Uses inherited __str__()print(repr(user)) # Uses inherited __repr__()print(user.__class__) # Uses inherited __class__#> <__main__.User object at 0x000002AADABBB690>#> <__main__.User object at 0x000002AADABBB690>#> <class '__main__.User'>
Python also supports “duck typing” – if it walks like a duck and quacks like a duck, it’s a duck. This means you don’t need formal inheritance to use objects interchangeably.
Consider a Duck class and Robot class both having a make_sound() method. Even though they’re completely unrelated classes, they can be used interchangeably because they implement the same interface, demonstrating Python’s duck typing.
PyInteractive Python
# Two completely different classes with the same interfaceclassDuck:defmake_sound(self):return"Quack!"classRobot:defmake_sound(self):return"Beep!"# They can be used interchangeablyanimals = [Duck(), Robot()]for animal in animals:print(animal.make_sound()) # Works for both!#> Quack!#> Beep!
This flexibility is one of Python’s strengths, but it also means we need to be thoughtful about when to use inheritance versus composition.
4. When to Use Inheritance
Use inheritance when you have a clear “is-a” relationship. Here are the key scenarios:
Scenario 1: Natural Hierarchies
This scenario shows inheritance at its best. Consider modeling different types of files that share common behavior but have specialized implementations.
Let’s create a parent class called File with attributes like filename and size.
PyInteractive Python
# Base file classclassFile:def__init__(self, filename, size):self.filename = filenameself.size = sizedefget_info(self):returnf"File: {self.filename} ({self.size} KB)"defprocess(self):raiseNotImplementedError("Subclass must implement")
Now let’s create two child classes: TextFile and ImageFile that both inherit from the File parent class, each implementing their own version of the process method.
Notice how both derived classes override the process() method to provide their own specific implementation while still inheriting the basic file attributes.
This works because TextFile “is-a” File and ImageFile “is-a” File. They share common behavior but have specialized implementations.
Scenario 2: Abstract Base Classes
What are Abstract Classes? Think of an abstract class as a blueprint or template that defines what methods a class must have, but doesn’t implement them. It’s like saying “every shape must have an area() method, but I won’t tell you how to calculate it – each specific shape will figure that out.” You can’t create objects directly from abstract classes – you must create child classes that implement the required methods first.
We’ll learn more on this in the next session.
Let’s create a simple abstract parent class called Shape that requires all shapes to have an area() method, then create concrete child classes Circle and Rectangle that must implement this method.
Both concrete classes must implement the abstract methods from Shape. This guarantees that any Shape object will have an area() method.
PyInteractive Python
# You can't instantiate the abstract base class# shape = Shape() # This would raise TypeError# But you can create concrete shapescircle = Circle(5)rectangle = Rectangle(4, 6)print(f"Circle area: {circle.area()}")print(f"Rectangle area: {rectangle.area()}")# Both shapes can be used interchangeablyshapes = [circle, rectangle]total_area =sum(shape.area() for shape in shapes)print(f"Total area: {total_area}")#> Circle area: 78.53975#> Rectangle area: 24#> Total area: 102.53975
Abstract base classes ensure that all derived classes implement required methods. This is useful when you want to guarantee a certain interface.
5. When to Use Composition
Use composition when you have “has-a” relationships or when you want more flexibility.
Scenario 1: Building Complex Objects
This scenario demonstrates how composition helps you build complex systems by combining simpler, focused components. Each component has a single responsibility.
Let’s create three separate component classes: FileWriter for saving data, Calculator for computations, and Logger for tracking operations.
PyInteractive Python
# Component classesclassFileWriter:defwrite(self, filename, content):print(f"Writing to {filename}: {content}")classCalculator:defadd(self, a, b):return a + bdefmultiply(self, a, b):return a * bclassLogger:deflog(self, message):print(f"LOG: {message}")
Now let’s create a ReportGenerator class that uses composition by containing instances of FileWriter, Calculator, and Logger objects, rather than inheriting from them. This generator will coordinate all three components to create reports.
PyInteractive Python
# ReportGenerator using compositionclassReportGenerator:def__init__(self):# Composition: ReportGenerator HAS these componentsself.file_writer = FileWriter()self.calculator = Calculator()self.logger = Logger()defgenerate_sales_report(self, sales_data):self.logger.log("Starting sales report generation") total =sum(sales_data) average = total /len(sales_data) report =f"Sales Report\nTotal: {total}\nAverage: {average}"self.file_writer.write("sales_report.txt", report)self.logger.log("Sales report completed")return report
The ReportGenerator class orchestrates all the components. Instead of being a FileWriter, Calculator, or Logger, it contains these objects and delegates specific tasks to them.
This scenario shows how composition enables flexible runtime behavior by allowing objects to use different strategies or policies without changing their core structure.
Let’s create three separate payment strategy classes: CreditCard, PayPal, and BankTransfer, each implementing their own payment method.
PyInteractive Python
# Different payment strategiesclassCreditCard:defprocess_payment(self, amount):returnf"Charged ${amount} to credit card"classPayPal:defprocess_payment(self, amount):returnf"Paid ${amount} via PayPal"classBankTransfer:defprocess_payment(self, amount):returnf"Transferred ${amount} via bank"
Now let’s create a CheckoutService class that uses composition by containing a payment strategy object. This service can change its payment processing behavior at runtime by switching between different strategy objects.
PyInteractive Python
# CheckoutService that can use different payment strategiesclassCheckoutService:def__init__(self):self.payment_method = CreditCard() # Default payment methoddefset_payment_method(self, payment_method):self.payment_method = payment_methodprint(f"Payment method changed to {payment_method.__class__.__name__}")defprocess_order(self, amount): result =self.payment_method.process_payment(amount)print(f"Order processed: {result}")return result
The checkout service delegates its payment processing to a strategy object. This makes it easy to swap payment methods without changing the checkout service itself.
PyInteractive Python
# Test strategy changes at runtimecheckout = CheckoutService()# Process with credit card (default)checkout.process_order(100.00)# Switch to PayPalcheckout.set_payment_method(PayPal())checkout.process_order(75.50)# Switch to bank transfercheckout.set_payment_method(BankTransfer())checkout.process_order(200.00)#> Order processed: Charged $100.0 to credit card#> Payment method changed to PayPal#> Order processed: Paid $75.5 via PayPal#> Payment method changed to BankTransfer#> Order processed: Transferred $200.0 via bank#> 'Transferred $200.0 via bank'
6. Runtime Behavior Changes with Composition
One of composition’s biggest advantages is the ability to change behavior at runtime.
Let’s create three separate notification classes: EmailNotifier, SMSNotifier, and PushNotifier, each implementing their own notification method.
Now let’s create a NotificationService class that uses composition by containing a notifier object. This service can change its notification behavior at runtime by switching between different notifier objects.
PyInteractive Python
# NotificationService that can change notification methodclassNotificationService:def__init__(self):self.notifier = EmailNotifier() # Default notifierdefset_notifier(self, notifier):self.notifier = notifierprint(f"Notification method changed to {notifier.__class__.__name__}")defnotify_user(self, message): result =self.notifier.send(message)print(result)return result
The notification service delegates its sending behavior to a notifier object. This makes it easy to swap notification methods without changing the service itself.
PyInteractive Python
# Test notification method changes at runtimeservice = NotificationService()# Send with email (default)service.notify_user("Welcome to our service!")# Switch to SMSservice.set_notifier(SMSNotifier())service.notify_user("Your order has shipped!")# Switch to push notificationservice.set_notifier(PushNotifier())service.notify_user("New message received!")#> Email sent: Welcome to our service!#> Notification method changed to SMSNotifier#> SMS sent: Your order has shipped!#> Notification method changed to PushNotifier#> Push notification sent: New message received!#> 'Push notification sent: New message received!'
With inheritance, you’d need to create different character classes. With composition, you just swap strategies at runtime.
7. The Liskov Substitution Principle
The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without breaking the program. This is a key test for proper inheritance.
Let’s create a parent class called Document with save and load methods, then create child classes PDFDocument and WordDocument that inherit from it and override the methods while maintaining the same interface.
PyInteractive Python
# Good inheritance example that follows LSPclassDocument:def__init__(self, title):self.title = titledefsave(self):returnf"Saving document: {self.title}"defload(self):returnf"Loading document: {self.title}"classPDFDocument(Document):defsave(self):returnf"Saving PDF: {self.title}"defload(self):returnf"Loading PDF: {self.title}"classWordDocument(Document):defsave(self):returnf"Saving Word doc: {self.title}"defload(self):returnf"Loading Word doc: {self.title}"# Function that works with any Documentdefprocess_document(doc): saved = doc.save() loaded = doc.load()return saved, loaded# Both work perfectly - LSP is maintainedpdf_doc = PDFDocument("Report.pdf")word_doc = WordDocument("Letter.docx")print("PDF:", process_document(pdf_doc))print("Word:", process_document(word_doc))#> PDF: ('Saving PDF: Report.pdf', 'Loading PDF: Report.pdf')#> Word: ('Saving Word doc: Letter.docx', 'Loading Word doc: Letter.docx')
This demonstrates proper inheritance – both derived classes can be used anywhere a Document is expected, and the program works correctly.
Now let’s see an example that violates LSP. We’ll create a parent class Storage that can save and load, then create a child class ReadOnlyStorage that inherits from it but can’t actually save, breaking the expected behavior.
PyInteractive Python
# Bad example that violates LSPclassStorage:def__init__(self, name):self.name = namedefsave(self, data):returnf"Saved data to {self.name}"defload(self):returnf"Loading data from {self.name}"classReadOnlyStorage(Storage): # Problem: can't save!defsave(self, data):raiseException("Cannot save to read-only storage!")defload(self):returnf"Loading data from read-only {self.name}"
Here’s a classic example of violating LSP. ReadOnlyStorage inherits from Storage, but it can’t fulfill the contract that all storage can save data.
PyInteractive Python
# Function expecting any storage to be writabledefbackup_data(storage, data):return storage.save(data)# This violates LSPregular_storage = Storage("disk")readonly_storage = ReadOnlyStorage("cdrom")print(backup_data(regular_storage, "important data")) # Works# print(backup_data(readonly_storage, "important data")) # Crashes! LSP violated#> Saved data to disk
When inheritance violates LSP, composition is usually a better choice.