Liskov Substitution Principle

RoadmapsPython

Overview

The Liskov Substitution Principle (LSP) states that objects of a superclass must be replaceable with objects of its subclasses without breaking the application. If `Square` inherits from `Rectangle`, a function expecting a `Rectangle` must work perfectly when passed a `Square`. To maintain this principle, a subclass must strictly honor three rules: - Match the parent's parameter count and types, ensuring callers can pass the expected arguments. - Return the same data type as the parent, never returning `None` when an object is expected. - Preserve the parent's behavioral contracts, meaning it must not introduce new unexpected side effects. Think of it as an electrical outlet: if you swap a standard wall outlet (superclass) with a smart outlet (subclass), it must still accept the same standard plugs and deliver the same voltage, or devices will fry. An often-missed edge case is raising new exception types in a subclass: class Cache: def get(self, key): return None class StrictCache(Cache): def get(self, key): raise KeyError(key) StrictCache().get('user') # Crashes expecting None! If your subclass raises an exception that the parent doesn't, callers won't have a `try/except` block to catch it and the application will crash. Another sharp edge is implicitly tightening parameter types in a subclass: class Parser: def parse(self, data): return len(data) # Accepts any sequence class ListParser(Parser): def parse(self, data): data.append(1) # Trap: Assumes a mutable list! ListParser().parse((1, 2)) # Crashes: 'tuple' object has no attribute 'append' If a subclass requires a stricter type than the parent, it will break callers who pass the broader types that the parent originally allowed.

It prevents unexpected runtime crashes and confusing bugs when working with inheritance, ensuring that polymorphism actually works safely.

Where used: `FastAPI` dependency injection, `Pydantic` custom validators, Standard library `abc` module

Why learn this

Code walkthrough

class Bird:
  def fly(self):
    return 'Flapping wings'

class Penguin(Bird):
  def fly(self):
    raise NotImplementedError('Cannot fly')

def make_bird_fly(bird):
  print(bird.fly())

penguin = Penguin()
make_bird_fly(penguin)  # Crashes!

Focus: raise NotImplementedError('Cannot fly')

Aha moment

class Rectangle:
  def __init__(self, w, h):
    self.w = w
    self.h = h
  def set_w(self, w): self.w = w
  def set_h(self, h): self.h = h
  def area(self): return self.w * self.h

class Square(Rectangle):
  def set_w(self, w): self.w = w; self.h = w
  def set_h(self, h): self.h = h; self.w = h

def resize(rect):
  rect.set_w(5)
  rect.set_h(4)
  print(rect.area())

sq = Square(2, 2)
resize(sq)

Prediction: What area is printed when `resize(sq)` is called?

Common guess: 20

The `resize` function assumes independent width and height, but `Square` links them. By the time `rect.area()` is called, the square's sides are `4x4`, printing `16`, showing why a `Square` is a mathematical rectangle but a bad programmatic subclass of `Rectangle`.

Common mistakes

Glossary

superclass
The original or parent class that other classes are built upon and inherit features from, e.g. `class Animal` is the superclass of `class Dog(Animal)`.
polymorphism
The ability of different objects to respond to the exact same method call in their own specific way, e.g. `dog.speak()` prints `'Woof'` while `cat.speak()` prints `'Meow'`.

Recall questions

Understanding checks

A subclass overrides a parent method `process(data: list)`. The subclass changes the signature to `process(data: list, strict: bool = False)`. Does this violate LSP?

No, this does not violate LSP.

Because `strict` has a default value (`False`), any caller that expects the parent class can still call `process(data)` with a single argument. The subclass honors the original contract.

What happens when this code is executed?

It raises an `AttributeError`.

`Child.get_status()` returns `None` (because it has only a `pass` statement), violating the implicit contract of `Parent` which returns a `str`. Calling `.lower()` on `None` crashes.

A parent class method `filter_items(self, items)` is designed to accept any iterable (like lists, tuples, or sets). A subclass overrides it with `filter_items(self, items: list)`, explicitly using `.sort()` which only exists on lists. Does this violate LSP?

Yes, it violates LSP by tightening the parameter type.

The parent contract allowed any iterable. By demanding a `list` (and using list-specific methods like `.sort()`), the subclass will crash if a caller passes a tuple, breaking the promise made by the parent class.

Practice tasks

Fix the Return Type Violation

The `SMSNotification` class violates LSP. The parent `Notification` class specifies that `send` should return a `bool` indicating success, but `SMSNotification` returns nothing (`None`). Modify `SMSNotification.send` so it returns `True` after printing the message, repairing the contract.

Challenge

Safe Export System

Create a base class `DataExporter` with a method `export(data: dict)`. Then implement a subclass `JSONExporter` that prints the `dict`, and a subclass `CSVExporter`. The base class contract requires `export` to accept a `dict`. `CSVExporter` must handle the `dict` properly without adding extra required parameters to `export`. Implement both classes so `process_export(exporter, {'id': 1})` works seamlessly for both.

Continue learning

Previous: Single inheritance and super()

Previous: Method Overriding

Return to Python Roadmap