Multilevel inheritance is what happens when a subclass becomes the parent of another subclass: GrandParent -> Parent -> Child, with each link a single inheritance relationship. The mechanics are the same as the single-inheritance chapter, stretched over more levels. This lesson focuses on what changes when the chain grows: how attribute lookup walks longer chains, how super() behaves at each level, what deeper hierarchies cost in maintainability, and when to flatten the tree instead.
A multilevel hierarchy is single inheritance, repeated. Each class names one parent, and the parent itself was already defined with its own parent. The result is a chain that walks up from the most specific class to the most general:
Three classes, three levels. Animal knows about names. Mammal adds fur color and the fact that mammals are warm-blooded. Dog adds breed and the ability to fetch. A Dog instance has all three sets of attributes and all three sets of methods, even though Dog only declared its own additions.
The diagram shows the cumulative effect. Each level adds to what the level above it provided. Reading from top to bottom shows what a Dog is: an object, then an Animal, then a Mammal, then a Dog. Each layer contributes something, and a Dog instance carries the union of everything.
The word "multilevel" means the chain has more than two links. There's nothing fundamentally new compared to the parent-child case: each link is still single inheritance. What changes is the maintenance cost and the reasoning effort, covered later in this lesson.
Python uses the same attribute-lookup algorithm for every class, regardless of how many levels are in the chain. Accessing instance.attribute makes Python check the instance itself first, then walk up the class chain from the most specific class to the most general. The first match wins.
For the Dog instance above, calling buddy.describe() triggers this lookup:
buddy itself for an attribute named describe. Not found.Dog (the instance's own class) for describe. Not found.Mammal (the next class up). Not found.Animal. Found! Use this version.object.)The exact same lookup, faster:
__mro__ (method resolution order) gives the order Python actually uses, in a single tuple. For single-inheritance chains like this one, it's the chain top to bottom. For multilevel inheritance, the MRO is always linear, no surprises.
A method defined at any level overrides the same-named method from any level above it. Once a class introduces an override, classes below it inherit the override rather than the version higher up:
Each call walks the chain from the instance's class upward. Animal() finds its own make_sound immediately. Mammal() finds the override on Mammal. Dog() doesn't define make_sound, so the lookup walks up to Mammal and uses its version. Puppy() defines its own and short-circuits the walk.
The takeaway: the lookup is always "most specific to most general", and the chain stops at the first match. Adding more levels doesn't change the rule; it gives the lookup more places to look.
super() Across Multiple LevelsIn a multilevel chain, super() walks up exactly one step at a time. When Dog.__init__ calls super().__init__(name, fur_color), it invokes Mammal.__init__. When Mammal.__init__ calls super().__init__(name), it invokes Animal.__init__. Each super() call moves one rung up the chain, and the chain itself is responsible for reaching the top.
This is why the standard pattern in a multilevel hierarchy is "every __init__ calls super().__init__(...)". If any link in the chain forgets the call, every class above that point gets skipped for initialization, and the attributes they would have set never appear on the instance.
A trace makes the pattern clearer:
The print statements run in this order because each super().__init__(...) call has to wait for the parent's __init__ to finish before continuing with the rest of the body. Dog.__init__ prints its line, then calls super(), which transfers control to Mammal.__init__. Mammal.__init__ prints its line, then calls super(), which transfers control to Animal.__init__. Animal.__init__ prints, then sets self.name, then returns. Control comes back to Mammal.__init__, which sets self.fur_color and returns. Control comes back to Dog.__init__, which sets self.breed.
The unwinding order matters: by the time Dog.__init__ finishes, the instance has all three attributes, each set by the class that owns that piece of state. Skipping any super() call skips everything above that point.
Method calls (not just __init__) use the same pattern. If describe on Dog calls super().describe(), that runs Mammal.describe. If Mammal.describe also calls super().describe(), that runs Animal.describe. Each class only needs to know about its immediate parent; the chain composes naturally.
The "extend rather than replace" pattern works the same way in a multilevel hierarchy. Each class can use super().method(...) to run the parent's version and then add its own contribution. With three or four levels, this produces a chain of contributions that compose into a final result.
A simple example: a describe method that each level extends with its own piece of information.
Each level adds one clause. Dog.describe calls super().describe(), which runs Mammal.describe. Mammal.describe calls super().describe(), which runs Animal.describe. Animal.describe doesn't call super() (it's at the top of the chain for this method), so the recursion stops and the strings get concatenated on the way back up.
The pattern is the same as cooperative multiple inheritance but easier to reason about since the chain is linear. There's only one direction super() can go at each level: straight up.
The arrows show how each level delegates upward before adding its own piece. The base case is Animal.describe, which returns the seed string. Each level above it wraps the seed with its own contribution.
Each super() call is one Python function call. For three or four levels of inheritance, the overhead is negligible. For chains seven or eight levels deep, the call cost starts to show in tight loops, though it's still fast in absolute terms. The deeper concern is comprehension: a developer reading the code has to walk every level to understand what describe actually returns.
super() always goes one step up the chain. To skip levels and call a specific ancestor's method directly, refer to the class name explicitly:
Animal.describe(self) calls the unbound method on Animal with the instance passed explicitly as self. The Mammal.describe override is bypassed.
This is rarely the right thing to do. It breaks the assumption that each level participates in the chain, which makes the code surprising for anyone tracing through it. It also tightly couples Dog to Animal rather than to its immediate parent: if Mammal ever changes how describe is computed, Dog skips that change without warning.
The legitimate uses are narrow: usually, helper code that intentionally wants the original behavior (debugging tools, serialization, comparing pre- and post-override outputs). For domain code, stick with super() and let each level do its job.
The cost of deep inheritance shows up most clearly in a phenomenon called the fragile base class problem. When a class has many subclasses (and especially many descendants several levels down), changes to the base class can break those descendants in unexpected ways, even if the change looks innocuous from the base's perspective.
A concrete example. Suppose Animal started out simple:
Now suppose Mammal and Dog and a half-dozen other descendants are all deployed, each with their own __init__ calling super().__init__(name). Months later, a feature request comes in: every animal needs a species field too. The natural change is to add a parameter to Animal.__init__:
Every descendant that called super().__init__(name) now breaks with TypeError: __init__() missing 1 required positional argument: 'species'. The base change rippled outward through every link in the chain, and each Mammal, Dog, and so on must be updated to pass the new argument.
The change doesn't have to be a constructor signature, either. Anything the base class does can affect descendants:
super() on.The problem gets worse the deeper the chain goes, since each link is a potential breaking point. A change at the top of a five-level hierarchy can break each of the four lower levels, and the developer making the change might not be aware of every descendant.
This isn't an argument against inheritance. It's an argument against deep inheritance. Two-level hierarchies (parent and child) are usually fine. Three levels are manageable. By four or five, the surface area where small base-class changes can break things has grown enough that many teams start preferring composition or shallower trees.
Multilevel inheritance isn't bad. It's the natural fit for hierarchies where each level represents a meaningful specialization that other classes also need. The taxonomy example (Animal -> Mammal -> Dog) makes sense because Mammal is a useful category in its own right: other classes (Cat, Horse, Whale) all need to inherit from Mammal, not from Animal directly.
A multilevel hierarchy makes sense when:
Mammal exists only because Dog needs it, the intermediate level isn't needed; put what Mammal provides directly on Dog.Employee -> Manager -> Director -> Executive. Each level has its own concept and its own users.A reasonable e-commerce hierarchy might look like this:
Each level represents a real category that other classes might use. PhysicalProduct is shared by PerishableProduct, Furniture, Electronics, and so on. PerishableProduct is a meaningful specialization with its own attribute (shelf life) and overridden shipping logic. The chain stops at three levels since there's nothing meaningful below PerishableProduct that would warrant a fourth.
A perishable product is a physical product is a product, and each "is a" relationship is real. If somebody asks "what's a PerishableProduct?" the answer is "a PhysicalProduct with a shelf life and faster shipping", and that's a clean answer. If a clean answer isn't possible at every level, the hierarchy probably has the wrong shape.
Industry practice over the past two decades has moved toward shallower inheritance and more composition. The reasons are concrete:
Discount strategy an Order uses can be changed by reassigning an attribute. Changing inheritance requires recreating the object.The replacement for PerishableProduct(PhysicalProduct) in the spirit of composition might look like this:
No hierarchy. The product holds a ShippingProfile as an attribute, and the profile encapsulates the weight and the surcharge. The same Product class handles digital goods (with a different shipping profile, or none at all), physical goods, perishables, and anything else. Adding a new shipping rule means changing ShippingProfile, not subclassing Product.
When does inheritance still win? When the variation isn't only data but is fundamental to what the object is. A User and an AdminUser aren't differentiated by an attribute; they have genuinely different methods and capabilities. A blog Post and a Comment have different fields and different operations. For those cases, inheritance captures the structure cleanly.
The practical rule most teams converge on: start with composition. Use inheritance when building parallel class structures that share most of their methods, and the relationship between them passes the "is-a" test. Keep the chain shallow, usually two levels, occasionally three. Anything deeper deserves a second look.
10 quizzes