Skip to main content

Inheritance in Python

OOP Architecture

Python Inheritance

Reuse functionality safely with base classes, composition, and `super()`.

Base and derived classes​

A class inherits by listing its parent in parentheses. The child gets every attribute and method the parent defines, and can override any of them:

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

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

class Dog(Animal):
def speak(self) -> str: # override
return f"{self.name} says woof"

rex = Dog("Rex")
print(rex.name) # Rex — Animal.__init__ ran, Dog didn't define one
print(rex.speak()) # Rex says woof — Dog's version wins

Dog never defines __init__, so Python walks up to Animal and uses its version. That lookup walk is the whole mechanism — when an attribute isn't found on the instance or its class, Python searches the parent chain (the details live in the method resolution order).

Extending vs replacing behavior​

Overriding speak above replaced the parent behavior. Often you want to extend it — do what the parent does, plus more. That's what super() is for:

class Puppy(Dog):
def __init__(self, name: str, age_months: int):
super().__init__(name) # let Animal set self.name
self.age_months = age_months

def speak(self) -> str:
return super().speak() + " (squeakily)"

biscuit = Puppy("Biscuit", 3)
print(biscuit.speak()) # Biscuit says woof (squeakily)
print(biscuit.age_months) # 3

Call super() when an override extends parent behavior; skip it when you intentionally replace that behavior entirely. The one place to be careful: if you define __init__ and skip super().__init__(), the parent's setup never runs and attributes like name silently don't exist.

Checking relationships​

isinstance checks an object against a class (including ancestors); issubclass compares classes directly:

print(isinstance(biscuit, Puppy))    # True
print(isinstance(biscuit, Animal)) # True — ancestors count
print(issubclass(Puppy, Animal)) # True
print(isinstance(biscuit, (int, Dog))) # True — tuple means "any of these"

Prefer these over type(x) == SomeClass, which ignores inheritance. And often you don't need a type check at all — see polymorphism and duck typing.

When not to inherit​

Inheritance says is-a. A Puppy is an Animal. But a Car is not an Engine — it has one. Reaching for inheritance just to reuse code couples the child to every detail of the parent. When the relationship is has-a, hold a reference instead:

class Engine:
def start(self) -> str:
return "engine running"

class Car:
def __init__(self):
self.engine = Engine() # composition: has-a

def start(self) -> str:
return self.engine.start() # delegate

print(Car().start()) # engine running

Composition keeps Car's public surface small — it exposes only what it delegates, while inheriting would drag in the entire Engine API whether it makes sense or not.

Patterns​

A base class can declare an interface by raising NotImplementedError, forcing children to override:

class Notification:
def send(self, message: str) -> None:
raise NotImplementedError

class EmailNotification(Notification):
def send(self, message: str) -> None:
print(f"Sending email: {message}")

class SlackNotification(Notification):
def send(self, message: str) -> None:
print(f"Posting to Slack: {message}")

EmailNotification().send("hi") # Sending email: hi
Notification().send("hi") # raises NotImplementedError

This is the lightweight version. It only fails when send is called; abstract base classes fail earlier, at instantiation, and are the better tool when the contract matters.

  • Use abstract base classes to enforce required methods.
  • Prefer composition when sharing behavior doesn't require overriding.
  • Keep hierarchies shallow; deep chains hinder comprehension.
  • Document extension points clearly (e.g., hook_ methods).
  • Combining behaviors from several parents? See multiple inheritance and mixins.

Frequently Asked Questions

When do I have to call super().__init__()?

Whenever your class defines its own __init__ and the parent's __init__ does setup you rely on (setting attributes, registering state). If your class doesn't define __init__ at all, the parent's runs automatically and no call is needed. Skipping it doesn't raise an error by itself — you just get AttributeError later when the missing attributes are touched.

class Child(Parent):
def __init__(self, extra):
super().__init__() # parent setup first
self.extra = extra

Should I use inheritance or composition?

Ask whether the relationship is "is-a" or "has-a". Inherit when the child genuinely is a specialized version of the parent and callers should treat them interchangeably. Compose when you only want to reuse functionality — holding the other object as an attribute and delegating keeps the coupling explicit and the public API minimal. When in doubt, composition is the safer default.

Can I inherit from built-ins like list or dict?

Yes, it's allowed — but the C-implemented methods don't always call your overrides (dict.update bypasses a custom __setitem__, for example), which leads to subtle bugs. For customized containers, prefer collections.UserList / UserDict, which are designed for subclassing, or wrap a plain list via composition.

from collections import UserList

class LoggingList(UserList):
def append(self, item):
print(f'adding {item}')
super().append(item)

Next up in your learning path