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