Inheritance lets one class take on the behaviour of another and add to it. Instead of copying a working method into three separate classes and maintaining three copies, you write it once in a base class and let the subclasses inherit it, overriding only what genuinely differs.
The mechanics are small: class Child(Base): makes every method of Base available on Child, and a method defined in Child with the same name replaces the inherited one. Everything else — the attributes set up by the parent's __init__, the methods it defines, its class attributes — comes along for free.
How do you define a subclass?
Put the parent class in parentheses after the class name. The parent is usually called the base class, the superclass or the parent; the new one is the subclass, derived class or child. All three names mean the same thing.
class Shape:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} with area {self.area()}"
def area(self):
raise NotImplementedError("subclasses must implement area()")
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def area(self):
return self.side ** 2
sq = Square(4)
print(sq.side)
print(sq.describe())
print("inherited describe, overridden area")
The key line is super().__init__(name). Without it, the parent's initialisation never runs and self.name never gets set, so describe() would fail with an AttributeError the moment it was called. super() is the standard way to run the parent's __init__ — it is covered in detail in the next lesson, but the pattern is worth learning early because forgetting it is the single most common inheritance bug.
What happens when a subclass defines a method with the same name?
The subclass version wins, for that class and any class inheriting from it. The parent's version is not gone, though — it is still there, and you can reach it deliberately. The usual pattern is to call the parent's method first, add your own behaviour, and return the result.
class Shape:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} with area {self.area()}"
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def area(self):
return self.side ** 2
def describe(self):
return super().describe() + f", side length {self.side}"
print(Square(4).describe())
That is the whole difference between overriding and replacing. In Square, the inherited describe() calls super().describe() to get the parent's text and then extends it. In a method that did not call super(), the parent's version would be skipped entirely and the child would be a replacement rather than an extension.
How do you check an inheritance relationship at runtime?
issubclass(Child, Base) asks whether one class descends from another, and isinstance(obj, Base) asks whether an object is an instance of a class or any of its subclasses. Both accept tuples, so isinstance(obj, (int, float)) is a clean way to accept either of two numeric types.
class Shape:
pass
class Square(Shape):
pass
sq = Square()
print("issubclass(Square, Shape):", issubclass(Square, Shape))
print("isinstance(sq, Shape):", isinstance(sq, Shape))
print("isinstance(sq, Square):", isinstance(sq, Square))
print("every class descends from object:", isinstance(sq, object))
print("accepting either numeric type:", isinstance(3, (int, float)))
Because isinstance follows the inheritance chain, a list is an isinstance of object — every class in Python descends from object, which supplies the default __repr__, __eq__ and everything else. That is the root of the whole class hierarchy.
Two cautions. issubclass works on classes, not instances, so passing an instance raises a TypeError. And a check that asks "is this object an instance of exactly this class" is usually a smell — you almost always want the chain-following behaviour of isinstance, because a subclass is still valid wherever its parent was.
| Check | Follows inheritance? | Use it for |
|---|---|---|
isinstance(obj, Base) | yes | accepting a family of related objects |
type(obj) is Base | no | rarely — it excludes subclasses |
issubclass(Child, Base) | yes | checking a class relationship, not an object |
obj.__class__ | no | debugging — it is the same as type(obj) |
What is the payoff of a shared base class?
The real benefit is that code can handle a family of different objects uniformly. A function that receives a Shape and calls area() on it works for every shape, and adding a new shape later does not require changing the function at all.
class Shape:
def __init__(self, name):
self.name = name
def area(self):
raise NotImplementedError
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def area(self):
return self.side ** 2
class Circle(Shape):
def __init__(self, radius):
super().__init__("circle")
self.radius = radius
def area(self):
return round(3.14159 * self.radius ** 2, 2)
def total_area(shapes):
return round(sum(shape.area() for shape in shapes), 2)
shapes = [Square(4), Circle(2), Square(3)]
for shape in shapes:
print(shape.name, "->", shape.area())
print("total area:", total_area(shapes))
This is polymorphism, and it is the reason inheritance exists. Without it, every new shape would mean editing every function that deals with shapes, and the code would fill up with if isinstance(...) checks. With a well-designed base class, those checks disappear.
When is inheritance the wrong tool?
Inheritance models an is-a relationship: a Square is a Shape. It does not model a has-a relationship: a Car has a Engine. If you find yourself inheriting from a class because you want access to one of its attributes, you probably want composition instead — holding an instance as an attribute rather than deriving from it.
Deep hierarchies are another warning sign. Every level of inheritance adds a layer of indirection, and a reader has to walk up the chain to understand what a method does. Two or three levels is usually manageable; six is a design smell.
There is also the fragile base class problem: if a base class changes its internals, every subclass can break. Keep base classes small and stable, and prefer adding new subclasses over modifying existing ones.
One practical warning about super() in an inheritance chain. If you skip it deliberately — say the child sets up its own attributes and wants nothing from the parent — that is a legitimate choice, but it stops the chain for anything further down. A grandchild that calls super() expecting its parent's parent to run will find that parent never got a chance to initialise.
| Relationship | Meaning | Use it for |
|---|---|---|
| is-a | the child is a specialised kind of the parent | stable taxonomies like Square and Circle |
| has-a | the child owns a collaborator | anything that could later need a swap |
| is-a, no state | the parent contributes behaviour only | mixins that add one capability |
Takeaways
class Child(Base):inherits everything the base defines.- Always call
super().__init__(...)unless you deliberately want to skip the parent's setup. - A method with the same name overrides the inherited one; call
super().method()to extend rather than replace. isinstance(obj, Base)follows the chain;type(obj) is Basedoes not.- Inheritance is for is-a. For has-a, hold an instance in an attribute instead.
Try it yourself: a notification hierarchy
Write a Notification base class that stores a recipient and message, and defines a send() method returning the message. Then write EmailNotification and SmsNotification, both calling super().__init__() and overriding send() to prefix the channel name.
Add a third subclass, UrgentNotification, that inherits from EmailNotification and calls super().send() before adding its own text. Confirm with isinstance that an UrgentNotification is also an EmailNotification and also a Notification. Finally, write one function that takes a list of mixed notification types and calls send() on each, and check that you did not need a single isinstance check inside it.