Inheritance models IS-A relationships
Before reaching for inheritance, ask one question: is this actually an IS-A relationship? A Dog IS-A Animal. A Car IS-A Vehicle. A SavingsAccount IS-A BankAccount. When you can say that cleanly, inheritance is probably the right tool.
The subclass inherits everything from the parent. Every method, every attribute. It can add new ones, and it can replace (override) ones that need different behavior.
1class Animal:2 def __init__(self, name):3 self.name = name45 def speak(self):6 raise NotImplementedError("subclasses must implement speak()")78 def describe(self):9 return f"I am {self.name}"1011class Dog(Animal):12 def speak(self):13 return f"{self.name} says: Woof!"1415class Cat(Animal):16 def speak(self):17 return f"{self.name} says: Meow."1819class Duck(Animal):20 def speak(self):21 return f"{self.name} says: Quack."2223animals = [Dog("Rex"), Cat("Luna"), Duck("Donald")]24for animal in animals:25 print(animal.speak())26 print(animal.describe()) # inherited from Animal, works on all
Every animal shares the describe() method, which was written once on the parent class. Each has its own speak() that overrides the parent's placeholder. This is inheritance doing what it is supposed to do: share common code, allow specialization where needed.
super(): calling the parent without hardcoding it
When you override a method in a subclass, you often still want the parent's version to run. The naive approach is to hardcode the parent class name:
1class Animal:2 def __init__(self, name):3 self.name = name45class Dog(Animal):6 def __init__(self, name, breed):7 Animal.__init__(self, name) # works, but brittle8 self.breed = breed
This breaks the moment you restructure the hierarchy. If Dog stops inheriting from Animal directly, every hardcoded reference breaks. Use super() instead:
1class Animal:2 def __init__(self, name):3 self.name = name4 print(f"Animal.__init__ called for {name}")56class Dog(Animal):7 def __init__(self, name, breed):8 super().__init__(name) # calls Animal.__init__, passes self automatically9 self.breed = breed10 print(f"Dog.__init__ added breed={breed}")1112class GuideDog(Dog):13 def __init__(self, name, breed, handler):14 super().__init__(name, breed) # calls Dog.__init__15 self.handler = handler16 print(f"GuideDog.__init__ added handler={handler}")1718g = GuideDog("Rex", "Labrador", "Alice")
Each class calls super().__init__() and the chain runs upward. You never hardcode a class name. If you restructure the hierarchy, the calls still work correctly.
Rule: whenever you override __init__, call super().__init__() first, before doing any of your own setup. This ensures the parent's state is initialized before you build on top of it.
MRO: the order Python searches for methods
When you call a method on an object, Python searches through a list of classes to find it. That list is the Method Resolution Order (MRO). For simple single inheritance it is obvious: check the instance's class, then the parent, then the parent's parent, up to object.
1class Animal:2 def speak(self):3 return "..."45class Dog(Animal):6 def speak(self):7 return "Woof"89class GuideDog(Dog):10 pass # no speak() override1112g = GuideDog("Rex", "Labrador", "Alice")13print(GuideDog.__mro__)
When you call g.speak(), Python checks GuideDog first (no speak), then Dog (found it), and uses that. The Animal version is never reached because Dog already has it.
Multiple inheritance and the diamond problem
Python allows a class to inherit from more than one parent. This creates ambiguity when both parents define the same method. The MRO resolves this unambiguously using an algorithm called C3 linearization.
1class A:2 def hello(self):3 return "A"45class B(A):6 def hello(self):7 return "B"89class C(A):10 def hello(self):11 return "C"1213class D(B, C):14 pass1516d = D()17print(d.hello()) # B -- not A, not C18print(D.__mro__)
The MRO for D is D, B, C, A, object. When hello() is called, Python finds it in B first and stops there. The order of parent classes in the class definition (D(B, C)) determines the search order.
In practice, multiple inheritance is rarely needed and often a sign that composition would be a better fit. Use it sparingly, and when you do, keep the MRO in mind.
When not to use inheritance
There is a principle called the Liskov Substitution Principle. In plain English: if Dog IS-A Animal, then anywhere you use an Animal, a Dog should work without anything breaking. If that is not true, the inheritance is wrong.
1class Rectangle:2 def __init__(self, width, height):3 self.width = width4 self.height = height56 def area(self):7 return self.width * self.height89class Square(Rectangle):10 def __init__(self, side):11 super().__init__(side, side)1213 # Problem: if someone sets width on a Square, height stays the same14 # A square is no longer a square.1516s = Square(5)17s.width = 10 # now s.height is still 518print(s.area()) # 50 -- but a 10x5 thing is not a square
A square IS-A rectangle mathematically, but in code, making Square inherit from Rectangle creates a footgun. Code that works with rectangles might adjust width and height independently, which breaks the square constraint.
Inherit for IS-A, compose for HAS-A
Another common mistake is inheriting just to reuse code:
1# Inheriting for code reuse, not IS-A2class Logger:3 def log(self, msg):4 print(f"[LOG] {msg}")56class UserService(Logger): # UserService IS-A Logger? No.7 def create_user(self, name):8 self.log(f"Creating user: {name}")9 # ... actual user creation1011# Better: HAS-A Logger12class UserService:13 def __init__(self):14 self._logger = Logger()1516 def create_user(self, name):17 self._logger.log(f"Creating user: {name}")18 # ... actual user creation
A UserService is not a kind of Logger. It uses a logger. When the relationship is HAS-A, the right pattern is to hold the dependency as an attribute, not to inherit from it. This is composition, covered in the last article of this series.
When in doubt, ask the IS-A question. If you cannot say "X is a kind of Y" naturally, do not use inheritance.