Loading episodes…
0:00 0:00

Liskov Substitution Principle (LSP) Visually Explained: A Python Guide

00:00
BACK TO HOME

Liskov Substitution Principle (LSP) Visually Explained: A Python Guide

10xTeam November 28, 2025 11 min read

The Liskov Substitution Principle (LSP) is the “L” in the SOLID acronym and a cornerstone of robust object-oriented design. It provides a critical guideline for creating reliable and predictable class hierarchies.

[!NOTE] The Liskov Substitution Principle states: If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of that program (e.g., correctness).

In simpler terms: A subclass should be able to do everything its parent class can do, without causing any surprises. If you swap a parent object with a child object, your program should not break.

Let’s visualize the core idea. Imagine a base class for “things that transport people.” A CruiseShip and a Ferry are excellent subtypes. You can substitute them for the base “transport” class when the context is “sea travel.” However, a SportsCar, while a form of transport, cannot be substituted if the program expects to cross an ocean. The car would break the program’s “correctness” in that context.

graph TD
    subgraph LSP Compliant
        A[SeaTransport] --> B[CruiseShip];
        A --> C[Ferry];
    end

    subgraph LSP Violation
        D[SeaTransport] -.-> E(SportsCar);
    end

    style E fill:#f9f,stroke:#333,stroke-width:2px

This principle ensures that polymorphism works as expected, allowing you to build flexible systems where components can be swapped interchangeably.

The Problem: A Fragile Class Hierarchy

Let’s model a common business scenario: a shipping service. Our system needs to handle different shipping providers. We’ll start by defining a common interface (an Abstract Base Class in Python) that all providers must follow.

Here’s our initial project structure:

shipping_project/
└── providers/
    ├── __init__.py
    ├── abstract.py
    ├── dhl.py
    ├── aramex.py
    └── local_shipping.py

We define a base ShippingProvider with three essential actions: shipping, tracking, and cancellation.

# providers/abstract.py
from abc import ABC, abstractmethod

class ShippingProvider(ABC):
    @abstractmethod
    def ship_package(self, package_details: dict) -> str:
        """Ships a package and returns a tracking ID."""
        pass

    @abstractmethod
    def track_package(self, tracking_id: str) -> str:
        """Returns the status of a package."""
        pass

    @abstractmethod
    def cancel_shipment(self, tracking_id: str) -> bool:
        """Cancels a shipment and returns success status."""
        pass

International providers like DHL and Aramex can implement all three methods without issue.

# providers/dhl.py
from .abstract import ShippingProvider

class DHLProvider(ShippingProvider):
    def ship_package(self, package_details: dict) -> str:
        print("DHL: Shipping package...")
        return "DHL12345"

    def track_package(self, tracking_id: str) -> str:
        return f"DHL: Package {tracking_id} is in transit."

    def cancel_shipment(self, tracking_id: str) -> bool:
        print(f"DHL: Shipment {tracking_id} cancelled.")
        return True

However, we have a small, local shipping company that is very efficient at delivery but doesn’t have the infrastructure to handle online cancellations. How do we model this? A common but incorrect approach is to raise an exception.

# providers/local_shipping.py
from .abstract import ShippingProvider

class LocalShippingProvider(ShippingProvider):
    def ship_package(self, package_details: dict) -> str:
        print("LocalShipping: Shipping package...")
        return "LOCAL-9876"

    def track_package(self, tracking_id: str) -> str:
        return f"LocalShipping: Package {tracking_id} is out for delivery."

    def cancel_shipment(self, tracking_id: str) -> bool:
-       # Logic to cancel shipment
-       return True
+       raise NotImplementedError("This provider does not support cancellation.")

[!WARNING] This implementation is a ticking time bomb! It breaks the Liskov Substitution Principle. The LocalShippingProvider pretends to be a full ShippingProvider, but it fails to uphold the contract by not properly implementing cancel_shipment.

A client function that operates on the ShippingProvider type now has to deal with unexpected behavior.

# client_code.py
from providers.abstract import ShippingProvider
from providers.dhl import DHLProvider
from providers.local_shipping import LocalShippingProvider

def process_cancellation(provider: ShippingProvider, tracking_id: str):
    print(f"Attempting to cancel {tracking_id} with {provider.__class__.__name__}")
    try:
        if provider.cancel_shipment(tracking_id):
            print("Cancellation successful!")
        else:
            print("Cancellation failed.")
    except NotImplementedError as e:
        print(f"ERROR: {e}")

# --- Usage ---
dhl = DHLProvider()
local_shipper = LocalShippingProvider()

process_cancellation(dhl, "DHL12345")
# Output:
# Attempting to cancel DHL12345 with DHLProvider
# DHL: Shipment DHL12345 cancelled.
# Cancellation successful!

process_cancellation(local_shipper, "LOCAL-9876")
# Output:
# Attempting to cancel LOCAL-9876 with LocalShippingProvider
# ERROR: This provider does not support cancellation.

The client code is forced to handle a special case. This is a clear signal that our abstraction is flawed. The LocalShippingProvider is not truly substitutable for a ShippingProvider.

The Solution: Segregating Interfaces for LSP Compliance

To fix this, we must stop forcing the LocalShippingProvider to conform to a contract it cannot fulfill. The solution is to create more granular abstractions, a technique closely related to the Interface Segregation Principle (which we’ll cover later in the SOLID series).

We’ll split our single ShippingProvider into two more focused abstract classes.

New Project Structure:

shipping_project_refactored/
└── providers/
    ├── __init__.py
    ├── abstract.py # Will contain both new abstract classes
    ├── dhl.py
    ├── aramex.py
    └── local_shipping.py
  1. BasicShippingProvider: An interface for providers that only offer shipping and tracking.
  2. CancellableShippingProvider: An interface that inherits from the basic one and adds the cancellation capability.
# providers/abstract.py (Refactored)
from abc import ABC, abstractmethod

# 1. The most fundamental interface
class BasicShippingProvider(ABC):
    @abstractmethod
    def ship_package(self, package_details: dict) -> str:
        pass

    @abstractmethod
    def track_package(self, tracking_id: str) -> str:
        pass

# 2. A more feature-rich interface that extends the basic one
class CancellableShippingProvider(BasicShippingProvider):
    @abstractmethod
    def cancel_shipment(self, tracking_id: str) -> bool:
        pass

Now, our concrete classes can inherit from the abstraction that accurately describes their capabilities.

# providers/local_shipping.py (Refactored)
- from .abstract import ShippingProvider
+ from .abstract import BasicShippingProvider

- class LocalShippingProvider(ShippingProvider):
+ class LocalShippingProvider(BasicShippingProvider):
    def ship_package(self, package_details: dict) -> str:
        print("LocalShipping: Shipping package...")
        return "LOCAL-9876"

    def track_package(self, tracking_id: str) -> str:
        return f"LocalShipping: Package {tracking_id} is out for delivery."

-   def cancel_shipment(self, tracking_id: str) -> bool:
-       raise NotImplementedError("This provider does not support cancellation.")

The LocalShippingProvider is now smaller, simpler, and honest about what it can do. It no longer violates LSP.

The DHLProvider and AramexProvider will inherit from the more capable CancellableShippingProvider.

# providers/dhl.py (Refactored)
from .abstract import CancellableShippingProvider

class DHLProvider(CancellableShippingProvider):
    # ... same implementation as before ...
    def ship_package(self, package_details: dict) -> str:
        print("DHL: Shipping package...")
        return "DHL12345"

    def track_package(self, tracking_id: str) -> str:
        return f"DHL: Package {tracking_id} is in transit."

    def cancel_shipment(self, tracking_id: str) -> bool:
        print(f"DHL: Shipment {tracking_id} cancelled.")
        return True

This new class hierarchy is robust, predictable, and compliant with LSP.

classDiagram
    direction LR
    class BasicShippingProvider {
        <<abstract>>
        +ship_package()
        +track_package()
    }
    class CancellableShippingProvider {
        <<abstract>>
        +cancel_shipment()
    }
    class LocalShippingProvider {
        +ship_package()
        +track_package()
    }
    class DHLProvider {
        +ship_package()
        +track_package()
        +cancel_shipment()
    }
    class AramexProvider {
        +ship_package()
        +track_package()
        +cancel_shipment()
    }

    CancellableShippingProvider --|> BasicShippingProvider : extends
    LocalShippingProvider --|> BasicShippingProvider : implements
    DHLProvider --|> CancellableShippingProvider : implements
    AramexProvider --|> CancellableShippingProvider : implements

Our client code is now much cleaner. The type system itself prevents us from making a mistake.

# client_code_refactored.py
from providers.abstract import BasicShippingProvider, CancellableShippingProvider
from providers.dhl import DHLProvider
from providers.local_shipping import LocalShippingProvider

# This function now correctly asks for a provider that CAN cancel.
def process_cancellation(provider: CancellableShippingProvider, tracking_id: str):
    print(f"Attempting to cancel {tracking_id} with {provider.__class__.__name__}")
    if provider.cancel_shipment(tracking_id):
        print("Cancellation successful!")

# --- Usage ---
dhl = DHLProvider()
local_shipper = LocalShippingProvider()

process_cancellation(dhl, "DHL12345")
# Output:
# Attempting to cancel DHL12345 with DHLProvider
# DHL: Shipment DHL12345 cancelled.
# Cancellation successful!

# The following line would cause a type error in a static checker like MyPy!
# process_cancellation(local_shipper, "LOCAL-9876")
# ERROR: Argument "provider" to "process_cancellation" has incompatible type
# "LocalShippingProvider"; expected "CancellableShippingProvider"

[!TIP] By adhering to LSP, we leverage the type system as a safety net. Static analysis tools can catch bugs before the code even runs, because we are no longer making false promises in our class designs.

Summary: Key Takeaways for LSP

This mindmap summarizes the core concepts and benefits of applying the Liskov Substitution Principle in your code.

mindmap
  root((Liskov Substitution Principle))
    Definition
      Subtypes must be substitutable for their base types
      Don't break the program's correctness
    LSP Violation Signs
      `NotImplementedError` in a subclass
      Empty method implementation in a subclass
      Client code checking `isinstance` of a subtype
    How to Fix Violations
      Segregate interfaces (ABCs)
      Create more specific base classes
      Subclasses inherit only what they can truly implement
    Benefits
      Ensures Polymorphism works correctly
      Improves Code Reusability
      Reduces unexpected runtime errors
      Enables safer refactoring
Deep Dive: LSP and Method Signatures LSP also imposes strict rules on the signatures of overridden methods, known as covariance and contravariance. * **Covariance (Return Types):** A subclass method can return a type that is *more specific* than the parent's return type. For example, if a parent method returns `Animal`, a child method can return `Dog`, because a `Dog` is still an `Animal`. * **Contravariance (Parameter Types):** A subclass method's parameters must be of the *same or a more general* type than the parent's. This is less intuitive. If a parent method accepts a `Dog`, the child method cannot restrict it to only accepting a `GoldenRetriever`. It must accept any `Dog` or even any `Animal` (a more general type). Python's type system helps enforce these rules, but understanding them is key to mastering LSP.


Test Your Knowledge: Quiz **Question 1:** You have a base class `Bird` with a method `fly()`. You create a subclass `Penguin`. Does this design violate LSP? Why? **Answer:** Yes, it violates LSP. A `Penguin` is a `Bird`, but it cannot `fly()`. A function expecting any `Bird` to be able to fly would break if given a `Penguin` object. The solution is to have a more general `Bird` class and a more specific `FlyingBird` subclass that other birds like `Sparrow` can inherit from. **Question 2:** A subclass method strengthens a precondition (e.g., the parent method accepts any integer, but the subclass method only accepts positive integers). Does this follow LSP? **Answer:** No. This violates LSP. A subclass cannot strengthen preconditions. It must be able to handle at least the same range of inputs as its parent. By requiring a more specific input, it breaks the substitution contract.

By following the Liskov Substitution Principle, you create class hierarchies that are logical, predictable, and resilient to change. It forces you to think carefully about your abstractions, leading to cleaner, more maintainable code.


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?