Inheritance was the first object-oriented concept that actually clicked for me, mostly because Python’s syntax for it is so lightweight that I could experiment with it in the interpreter for ten minutes and understand the shape of it. But “lightweight syntax” hid some real depth — method resolution, super(), overriding versus extending, and how attribute lookup climbs the class hierarchy. This guide covers basic (single) inheritance from the ground up, with enough internal detail that you understand not just how to write it, but why it behaves the way it does.
The Core Idea
Inheritance lets one class (the subclass or child class) acquire the attributes and methods of another class (the superclass, base class, or parent class), so you can reuse and specialize behavior instead of rewriting it.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
def describe(self):
return f"{self.name} says {self.speak()}"
class Dog(Animal):
def speak(self):
return "Woof"
d = Dog("Rex")
print(d.describe()) # Rex says Woof
Dog didn’t redefine __init__ or describe — it inherited both from Animal, and only overrode speak. When describe calls self.speak(), it uses Dog‘s version, not Animal‘s, because method lookup happens on the actual runtime type of self, not on where describe is defined. This is polymorphism in action, and it’s the entire point of inheritance: shared structure, specialized behavior.
super(): Extending Rather Than Replacing
Overriding a method entirely (like speak above) is common, but often you want to extend a parent’s behavior rather than fully replace it. super() gives you access to the parent class’s version of a method from within the child:
class Animal:
def __init__(self, name):
self.name = name
self.energy = 100
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
d = Dog("Rex", "Labrador")
print(d.name, d.energy, d.breed) # Rex 100 Labrador
Without super().__init__(name), Dog would need to duplicate self.name = name and self.energy = 100 itself, and any future change to Animal.__init__ wouldn’t automatically propagate to Dog. Calling super() keeps the initialization logic in one place.
This pattern extends to any method, not just __init__:
class Animal:
def describe(self):
return f"This is an animal named {self.name}"
class Dog(Animal):
def describe(self):
base = super().describe()
return base + ", and it's a very good dog"
print(Dog().describe() if False else "") # illustrative
Attribute Lookup Order
When you access instance.attribute, Python searches:
- The instance’s own
__dict__. - The class’s
__dict__. - Each base class’s
__dict__, in MRO order, up the chain. object, the ultimate base of every class in Python 3.
class Base:
greeting = "Hello from Base"
class Child(Base):
pass
c = Child()
print(c.greeting) # Hello from Base (found on Base, not Child or c)
c.greeting = "Hi from instance"
print(c.greeting) # Hi from instance
print(Child.greeting) # Hello from Base (class attribute untouched)
Setting c.greeting creates a new entry in the instance’s own __dict__; it doesn’t touch Base.greeting at all. This is why mutating a class attribute through self.x = ... shadows rather than modifies the inherited value — a distinction that trips people up constantly, especially with mutable class attributes (lists, dicts).
Checking Relationships: isinstance and issubclass
print(isinstance(d, Dog)) # True
print(isinstance(d, Animal)) # True — Dog IS-A Animal
print(issubclass(Dog, Animal))# True
print(Dog.__bases__) # (<class 'Animal'>,)
print(Dog.__mro__) # (Dog, Animal, object)
isinstance checks against the entire inheritance chain, not just the exact type — a Dog instance is also, correctly, an Animal instance. This matters for writing functions that should accept any subclass:
def feed(animal: Animal):
print(f"Feeding {animal.name}")
feed(d) # works fine, Dog is-an Animal
Overriding vs Extending vs Not Touching
Three distinct things a subclass can do with an inherited method:
class Shape:
def area(self):
raise NotImplementedError
def describe(self):
return f"Area: {self.area()}"
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): # override — fully replaces parent's behavior
return self.side ** 2
class LoggedSquare(Square):
def area(self): # extend — calls parent, adds behavior
result = super().area()
print(f"Computed area: {result}")
return result
class TinySquare(Square):
pass # inherit unchanged — uses Square.area directly
Shape.area deliberately raises NotImplementedError — a common pattern for defining an interface that concrete subclasses are required to implement. This is Python’s lightweight stand-in for an abstract method (the more formal version uses abc.ABC and @abstractmethod).
Enforcing Implementation with abc
If you want Python to actually refuse to instantiate a class that hasn’t implemented required methods, use the abc module:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
s = Shape()
# TypeError: Can't instantiate abstract class Shape with abstract method area
This turns a documentation convention (raise NotImplementedError) into an enforced contract, checked at instantiation time.
Class Attributes vs Instance Attributes in Inheritance
class Vehicle:
wheels = 4 # class attribute, shared unless overridden
class Motorcycle(Vehicle):
wheels = 2 # overrides the inherited class attribute
class Car(Vehicle):
pass
print(Motorcycle().wheels) # 2
print(Car().wheels) # 4 (inherited from Vehicle)
Motorcycle doesn’t modify Vehicle.wheels — it defines its own wheels attribute directly on Motorcycle, which takes priority in the MRO search order for any Motorcycle instance.
Real-World Use Case: A Small Plugin System
Inheritance shines when you have a family of related behaviors that share structure but differ in specifics — a very common real pattern is a base class defining a template, and subclasses filling in the specifics:
class DataExporter:
def export(self, data):
formatted = self.format(data)
self.write(formatted)
def format(self, data):
raise NotImplementedError
def write(self, content):
print(content) # default: print to console
class CSVExporter(DataExporter):
def format(self, data):
return "\n".join(",".join(row) for row in data)
class JSONExporter(DataExporter):
def format(self, data):
import json
return json.dumps(data)
CSVExporter().export([["a", "b"], ["1", "2"]])
JSONExporter().export([["a", "b"], ["1", "2"]])
This is the template method pattern — export defines the overall algorithm shape, and subclasses customize only the format step. It’s one of the most practical, everyday uses of basic inheritance in real projects.
Common Mistakes
- Forgetting
super().__init__()and having to duplicate parent initialization logic manually, which then drifts out of sync when the parent changes. - Deep inheritance chains (five or six levels) that make it hard to trace where a method actually comes from — a strong signal that composition might be a better fit than more inheritance.
- Overriding
__init__without matching the parent’s expected arguments, breaking any code that constructs the subclass expecting the parent’s signature. - Mutating a shared mutable class attribute through an instance, expecting per-instance behavior (lists and dicts as class attributes are a classic trap — see also default value handling for constructors).
FAQs
Q: Does every Python class inherit from something, even if I don’t write it explicitly? Yes — every class implicitly inherits from object if no other base is specified, which is where common methods like __repr__, __eq__, and __hash__ ultimately come from by default.
Q: What’s the difference between overriding a method and shadowing an attribute? They’re the same underlying mechanism — defining a name in the subclass (or instance) that takes priority over the inherited one during lookup — whether that name is a method or a plain attribute.
Q: When should I prefer composition over inheritance? When the relationship isn’t truly “is-a.” If a Car “has-a” Engine, composition (self.engine = Engine()) is more honest and flexible than trying to make Car inherit from Engine.
Summary
Basic inheritance in Python lets a subclass reuse a superclass’s attributes and methods while overriding or extending specific pieces of behavior, all governed by a predictable attribute-lookup order. super() is the tool for extending rather than replacing parent behavior, isinstance/issubclass let you check relationships, and the abc module lets you turn “should implement this” into an enforced rule. Understood well, inheritance is a clean way to express shared structure — understood poorly, it becomes a tangle of deep hierarchies that’s harder to reason about than the duplication it was meant to avoid.
