Python Inheritance

Inheritance lets one class (a subclass or child class) reuse the attributes and methods of another class (a superclass or parent class), while adding or changing behavior of its own. Instead of copy-pasting code between similar classes, you write the shared logic once in a base class and let related classes inherit it. This is one of the four pillars of object-oriented programming, and Python’s flexible, dynamic object model makes it especially easy to use.

Overview / How it works

Every class in Python inherits from something. If you don’t specify a parent explicitly, your class implicitly inherits from object, the root of Python’s class hierarchy. When you write class Dog(Animal):, Python creates a new class object Dog whose internal __bases__ tuple contains Animal. This doesn’t copy any code—instead, Python records a relationship that is consulted at attribute-lookup time.

When you access some_dog.speak(), Python looks up speak using the Method Resolution Order (MRO): it first checks the instance’s own __dict__, then the instance’s class (Dog), then that class’s bases in order (Animal), and so on up to object. The first matching attribute found wins. This is why a subclass can override a method simply by defining a method with the same name: it appears earlier in the search order than the parent’s version, so it “shadows” it.

Because the lookup happens dynamically at call time (not at class-definition time), inheritance in Python is very flexible: you can even reassign a method on a base class after subclasses exist, and the change is visible everywhere, since no copying ever occurred. The MRO itself is computed once, when the class is created, using an algorithm called C3 linearization, and is stored on the class as ClassName.__mro__.

Syntax

class Base:
    ...

class Derived(Base):
    def __init__(self, x, y):
        super().__init__(x)
        self.y = y

    def method(self):
        result = super().method()
        return result
  • class Derived(Base): — declares Derived as a subclass of Base; Base can be any existing class, including a built-in like list or dict.
  • super() — returns a proxy object that forwards attribute access to the next class in the MRO after the current one, so you can call the parent’s implementation without naming it directly.
  • super().__init__(x) — the standard way to let the parent class initialize the attributes it’s responsible for, before the child adds its own.
  • class C(A, B): — multiple inheritance: C inherits from both A and B, combining their behavior according to the MRO.
  • isinstance(obj, Cls) and issubclass(Sub, Cls) — check inheritance relationships at runtime; both return True for any ancestor class, not just the immediate parent.

Examples

Example 1: Basic inheritance and overriding

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} makes a sound."


class Dog(Animal):
    def speak(self):
        return f"{self.name} barks."


class Cat(Animal):
    pass


animals = [Dog("Rex"), Cat("Whiskers"), Animal("Generic")]
for a in animals:
    print(a.speak())

Output:

Rex barks.
Whiskers makes a sound.
Generic makes a sound.

Dog overrides speak() with its own version, so calling it uses Dog‘s method. Cat defines no methods at all—it inherits __init__ and speak unchanged from Animal. This is the essence of inheritance: reuse by default, override only what needs to differ.

Example 2: Extending behavior with super()

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r}, {self.salary})"

    def annual_salary(self):
        return self.salary * 12


class Manager(Employee):
    def __init__(self, name, salary, bonus):
        super().__init__(name, salary)
        self.bonus = bonus

    def annual_salary(self):
        return super().annual_salary() + self.bonus


m = Manager("Ana", 5000, 3000)
print(m)
print(m.annual_salary())
print(isinstance(m, Employee))
print(issubclass(Manager, Employee))

Output:

Manager('Ana', 5000)
63000
True
True

Manager.__init__ calls super().__init__(name, salary) so it doesn’t have to repeat the assignment logic already written in Employee; it only adds self.bonus. Likewise, annual_salary calls super().annual_salary() to get the base calculation and then adds the bonus, instead of re-implementing the multiplication. Using type(self).__name__ in __repr__ means the inherited method automatically prints the correct subclass name. Note that isinstance and issubclass both confirm the inheritance relationship even though Manager is not literally an Employee object—it’s a more specific kind of one.

Example 3: Multiple inheritance and the MRO

class Flyer:
    def move(self):
        return "flies through the air"


class Swimmer:
    def move(self):
        return "swims through water"


class Duck(Flyer, Swimmer):
    def __init__(self, name):
        self.name = name


d = Duck("Donald")
print(d.move())
print(Duck.__mro__)

Output:

flies through the air
(, , , )

Duck inherits from both Flyer and Swimmer, which each define a conflicting move() method. Python resolves the conflict using the MRO, printed via Duck.__mro__: it lists Duck, then Flyer, then Swimmer, then object, in the exact order Python searches for attributes. Because Flyer is listed first in class Duck(Flyer, Swimmer):, its move() wins.

Under the hood

When you call d.move(), Python does not search Duck, Flyer, and Swimmer as separate, unrelated lookups. It first builds the MRO exactly once, when the class statement executes, using the C3 linearization algorithm. C3 guarantees that a class always appears before its parents, and that the left-to-right order you wrote in the class definition is respected. The resulting list is cached as Duck.__mro__ and reused for every attribute access on any Duck instance.

super() works by looking at __mro__ too: called inside a method of class X, super() finds X in the current instance’s MRO and returns a proxy that starts searching from the next class after X. This is why super() is described as “cooperative”—in a chain of multiple inheritance, each class’s super() call passes control to the next class in the MRO, not necessarily its own direct parent, so a full call chain can visit every class exactly once even in complex hierarchies.

Common Mistakes

Mistake 1: Forgetting to call super().__init__()

If a subclass defines its own __init__ but never calls the parent’s, the parent’s setup code never runs, and the resulting object is missing attributes the parent normally sets up:

class Base:
    def __init__(self, x):
        self.x = x

class Child(Base):
    def __init__(self, x, y):
        self.y = y  # forgot super().__init__(x)

c = Child(1, 2)
print(c.x)

Running this raises AttributeError: 'Child' object has no attribute 'x', because Base.__init__ was never executed and self.x was never assigned. The fix is to always call super().__init__(x) at the start of the subclass’s __init__ (unless you deliberately intend to skip the parent’s setup).

Mistake 2: Mutable class attributes shared across instances

A list or dict assigned directly in the class body is a single object shared by the class itself and every instance, including subclass instances—it is not automatically re-created per object:

class Base:
    items = []  # class attribute, shared by all instances!

    def add(self, item):
        self.items.append(item)

class Sub(Base):
    pass

a = Sub()
b = Sub()
a.add("apple")
b.add("banana")
print(a.items)
print(b.items)

Output:

['apple', 'banana']
['apple', 'banana']

Both instances see both items, because items lives on the class, not the instance. The fix is to create the mutable object inside __init__ so each instance gets its own:

class Base:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

class Sub(Base):
    pass

a = Sub()
b = Sub()
a.add("apple")
b.add("banana")
print(a.items)
print(b.items)

Output:

['apple']
['banana']

Best Practices

  • Prefer super().__init__(...) over calling Base.__init__(self, ...) directly—it respects the MRO and keeps multiple inheritance working correctly.
  • Follow the “is-a” test: only inherit when the subclass is genuinely a more specific version of the parent (a Dog is an Animal). If you just want to reuse some functionality without an is-a relationship, use composition (“has-a”) instead.
  • Keep base classes focused on behavior truly shared by all subclasses; avoid deep, sprawling hierarchies that are hard to reason about.
  • Use isinstance() rather than comparing type(obj) == SomeClass, since isinstance correctly accounts for inheritance.
  • Document which methods a base class expects subclasses to override, especially if the base class is not meant to be instantiated directly (consider the abc module’s ABC and @abstractmethod for that case).
  • Avoid mutable default values as class attributes; initialize them in __init__ instead.
  • When overriding a method, keep its signature compatible with the parent’s so instances remain interchangeable (the Liskov substitution principle).

Practice Exercises

  • Create a base class Shape with a method area() that returns 0. Create subclasses Rectangle and Circle that override area() using their own stored dimensions. Put instances of both in a list and print each one’s area in a loop.
  • Write a class Vehicle with an __init__ that stores make and model, and a subclass ElectricVehicle that uses super().__init__() and adds a battery_kwh attribute plus its own __repr__. Verify with isinstance() that an ElectricVehicle is also a Vehicle.
  • Define two classes Walker and Climber, each with a move() method returning a different string, then a class Goat(Walker, Climber) with no methods of its own. Print Goat.__mro__ and predict, before running it, which move() a Goat instance will use.

Summary

  • Inheritance lets a subclass reuse and extend the attributes and methods of a parent class, declared with class Child(Parent):.
  • Attribute lookup follows the Method Resolution Order (MRO), computed via C3 linearization and inspectable as ClassName.__mro__.
  • A subclass overrides a parent method simply by redefining it; super() lets the override still call the parent’s version cooperatively.
  • Multiple inheritance is supported and resolved left-to-right according to the MRO, but should be used carefully to avoid ambiguous designs.
  • isinstance() and issubclass() check inheritance relationships, including indirect (grandparent) ones.
  • Common bugs include forgetting super().__init__() and accidentally sharing mutable class attributes across instances.