Type something to search...
Inheritance and super() in Python: How Method Resolution Order Works

Inheritance and super() in Python: How Method Resolution Order Works

Inheritance looks simple when there's one parent class: the child gets the parent's methods, overrides a few, and calls super() to reach the original. Most explanations stop there, and so super() gets described as "call the parent class". That description is wrong, and the moment you use multiple inheritance or mixins, the wrongness shows: super() calls a class that isn't a parent at all, an __init__ runs in an order you didn't expect, or a method silently never runs.

What super() actually does is follow the method resolution order (MRO), a single list of classes that Python computes for every class. Once you know how that list is built and how super() walks it, multiple inheritance stops being mysterious.

This post covers single inheritance and overriding, what super() really does, how the MRO is computed with the C3 algorithm, cooperative multiple inheritance with **kwargs, mixins, and the pitfalls worth knowing. If classes are new to you, start with classes and objects in Python.

Single Inheritance

A class lists its base class in parentheses. The subclass gets every attribute and method of the base, and can add or replace any of them:

# notifications.py
class Notifier:
    channel = "generic"

    def __init__(self, recipient: str) -> None:
        self.recipient = recipient

    def format(self, message: str) -> str:
        return f"[{self.channel}] {message}"

    def send(self, message: str) -> None:
        print(f"to {self.recipient}: {self.format(message)}")


class EmailNotifier(Notifier):
    channel = "email"


class SmsNotifier(Notifier):
    channel = "sms"
    max_length = 30

    def __init__(self, recipient: str, sender_id: str) -> None:
        super().__init__(recipient)
        self.sender_id = sender_id

    def format(self, message: str) -> str:
        text = super().format(message)
        if len(text) > self.max_length:
            text = text[: self.max_length - 3] + "..."
        return text


EmailNotifier("ada@example.com").send("Your order has shipped")
SmsNotifier("+15550100", sender_id="SHOP").send("Your order has shipped and arrives Tuesday")
to ada@example.com: [email] Your order has shipped
to +15550100: [sms] Your order has shippe...

Three different things are going on:

  • EmailNotifier overrides only a class attribute. It inherits __init__, format, and send unchanged. Because format reads self.channel, and lookup starts at the object's own class, it picks up "email".
  • SmsNotifier.__init__ extends the parent's initializer. It calls super().__init__(recipient) so the base class can set up its part of the object, then adds its own attribute.
  • SmsNotifier.format extends a method. It gets the base formatting through super().format(message) and then truncates it. Note that send is inherited and calls self.format(...), which finds the overridden version. The base class calls the subclass's code without knowing it exists. That's the key payoff of inheritance.

Checking Relationships

isinstance and issubclass respect inheritance:

sms = SmsNotifier("+15550100", "SHOP")
print(isinstance(sms, SmsNotifier), isinstance(sms, Notifier), isinstance(sms, EmailNotifier))
print(issubclass(SmsNotifier, Notifier), issubclass(bool, int))
True True False
True True

An SmsNotifier is a Notifier, so it can be used anywhere a Notifier is expected. (And yes, bool really is a subclass of int in Python, which is why True + True == 2.)

Forgetting to Call super().__init__

If a subclass defines __init__ and doesn't call the parent's, the parent's setup never runs:

class Notifier:
    def __init__(self, recipient: str) -> None:
        self.recipient = recipient


class SlackNotifier(Notifier):
    def __init__(self, recipient: str, workspace: str) -> None:
        self.workspace = workspace  # forgot super().__init__(recipient)


slack = SlackNotifier("#alerts", "acme")
print(slack.recipient)
AttributeError: 'SlackNotifier' object has no attribute 'recipient'

The error shows up far from the cause, often in an inherited method. Whenever you override __init__, call super().__init__(...), usually as the first line.

Every Class Has an MRO

When you access obj.attr, Python searches a list of classes in order and uses the first one that defines attr. That list is the class's method resolution order, and you can look at it:

print(SmsNotifier.__mro__)
(<class '__main__.SmsNotifier'>, <class '__main__.Notifier'>, <class 'object'>)

With single inheritance it's just the chain from the class up to object, which every class ultimately inherits from. SmsNotifier.mro() returns the same thing as a list.

Things get interesting with more than one base class.

Multiple Inheritance and the Diamond

Python lets a class have several bases. The classic tricky case is the diamond, where two bases share a common ancestor:

class A:
    def greet(self) -> str:
        return "A"


class B(A):
    def greet(self) -> str:
        return "B -> " + super().greet()


class C(A):
    def greet(self) -> str:
        return "C -> " + super().greet()


class D(B, C):
    def greet(self) -> str:
        return "D -> " + super().greet()


print(D().greet())
print([cls.__name__ for cls in D.__mro__])
print(B().greet())
D -> B -> C -> A
['D', 'B', 'C', 'A', 'object']
B -> A

Look at the first line carefully. B.greet calls super().greet(), and B's only parent is A. Yet when called on a D instance, super() inside B went to C, a class B knows nothing about. When called on a plain B instance, the same line went to A.

That's the real definition of super():

super() returns a proxy that looks up the method in the next class after the current one in the MRO of the object's type (type(self).__mro__).

It is not "my parent". It's "whoever comes after me in this object's MRO". For a D object the MRO is D, B, C, A, object, so after B comes C. For a B object the MRO is B, A, object, so after B comes A.

This is what makes the diamond work: every class in the hierarchy runs exactly once, and A runs last, after both of its subclasses. Without it, a naive "call each parent" approach would run A.greet twice.

The Explicit Form of super()

Inside a method, super() with no arguments is shorthand for super(CurrentClass, self). The compiler fills in the current class for you. The two-argument form makes the "start searching after this class" behavior explicit:

d = D()
print(super(B, d).greet())  # start after B in D's MRO
print(super(D, d).greet())  # start after D
C -> A
B -> C -> A

You'll rarely need the explicit form in modern code, but it's a good way to convince yourself of how the lookup works.

How the MRO Is Computed: C3 Linearization

Python builds the MRO with the C3 linearization algorithm (used since Python 2.3). It guarantees three properties:

  1. A class comes before its bases. D comes before B and C.
  2. Bases keep the order you listed them. class D(B, C) puts B before C.
  3. Monotonicity. If B comes before A in B's own MRO, it comes before A in every subclass's MRO too. A subclass never reorders its ancestors' relationships.

The computation is: the MRO of a class is the class itself, followed by a merge of its bases' MROs and the list of bases. The merge repeatedly takes the first head (first element of a list) that doesn't appear in the tail (any position other than first) of any other list.

For class D(B, C):

L[B] = [B, A, object]
L[C] = [C, A, object]

L[D] = D + merge([B, A, object], [C, A, object], [B, C])

Step through the merge:

  1. Candidate B: not in any tail. Take it. Lists become [A, object], [C, A, object], [C].
  2. Candidate A: it's in the tail of [C, A, object], so not yet. Try the next list's head, C: not in any tail. Take it. Lists become [A, object], [A, object], [].
  3. Candidate A: take it. Then object.

Result: D, B, C, A, object. Exactly what __mro__ printed. The rule in step 2 is what pushes A after both B and C: a shared ancestor waits until every class that depends on it has gone first.

When No MRO Exists

Sometimes the constraints contradict each other, and Python refuses to create the class:

class A: pass
class B(A): pass

class C(A, B): pass
TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B

class C(A, B) asks for A before B (the order you listed them), but B is a subclass of A, which requires B before A. Both can't be true. The fix is almost always to list the more specific class first, class C(B, A), or to drop A since B already includes it.

The same error happens when two bases disagree about the order of their shared bases, for example class P(X, Y) and class Q(Y, X) combined in class Z(P, Q). If you hit this in real code, the hierarchy is usually trying to do too much.

Cooperative Multiple Inheritance

The diamond example worked because every class called super(). That's the deal with multiple inheritance in Python: it only works if every class in the chain cooperates by calling super() and passing along what it doesn't use.

The hard part is __init__, because different classes need different arguments. The standard solution is keyword-only arguments plus **kwargs: each class takes the arguments it needs and forwards the rest.

# cooperative.py
from datetime import UTC, datetime
from typing import Any


class Model:
    def __init__(self, *, id: int, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.id = id
        print("Model.__init__")


class TimestampMixin:
    def __init__(self, *, created_at: datetime | None = None, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.created_at = created_at or datetime.now(UTC)
        print("TimestampMixin.__init__")


class SoftDeleteMixin:
    def __init__(self, *, deleted: bool = False, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.deleted = deleted
        print("SoftDeleteMixin.__init__")


class User(TimestampMixin, SoftDeleteMixin, Model):
    def __init__(self, *, email: str, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.email = email
        print("User.__init__")


user = User(id=7, email="ada@example.com", created_at=datetime(2026, 9, 1, tzinfo=UTC))
print([cls.__name__ for cls in User.__mro__])
print(user.id, user.email, user.deleted, user.created_at.date())
Model.__init__
SoftDeleteMixin.__init__
TimestampMixin.__init__
User.__init__
['User', 'TimestampMixin', 'SoftDeleteMixin', 'Model', 'object']
7 ada@example.com False 2026-09-01

Follow the call chain through the MRO. User.__init__ takes email and passes id and created_at on. TimestampMixin takes created_at, passes id. SoftDeleteMixin takes nothing it was given (it uses its default) and passes id. Model takes id and passes an empty **kwargs to object.__init__, which accepts no extra arguments. Each class runs exactly once.

The prints appear in reverse MRO order only because each class calls super().__init__ before printing. The calls themselves happen in MRO order.

The rules for cooperative classes, which Raymond Hettinger laid out in his well-known "Python's super() considered super!" article:

  • Every class in the chain calls super(), including mixins whose bases are just object. One class that doesn't call it breaks the chain for everything after it.
  • Use keyword arguments and strip off the ones you consume, forwarding the rest with **kwargs. Positional arguments don't work, because no class knows its position in the final MRO.
  • Something at the end must accept the final call. Here that's object.__init__, so by the time the chain reaches it, every argument must have been consumed.

That last rule gives you free error checking. A typo or unexpected argument reaches object and fails loudly:

User(id=8, email="x@example.com", nickname="x")
TypeError: object.__init__() takes exactly one argument (the instance to initialize)

The *args and **kwargs mechanics are covered in args and kwargs in Python.

Mixins

A mixin is a small class that adds one focused piece of behavior and is designed to be combined with other classes rather than used alone. Mixins are where super() and the MRO earn their keep, because they let you layer behavior without editing the classes underneath:

# storage.py
class Storage:
    def __init__(self) -> None:
        self.data: dict[str, str] = {}

    def save(self, key: str, value: str) -> None:
        self.data[key] = value


class LoggingMixin:
    def save(self, key: str, value: str) -> None:
        print(f"saving {key!r}")
        super().save(key, value)


class ValidationMixin:
    def save(self, key: str, value: str) -> None:
        if not key.isidentifier():
            raise ValueError(f"invalid key: {key!r}")
        super().save(key, value)


class SafeStorage(LoggingMixin, ValidationMixin, Storage):
    pass


store = SafeStorage()
store.save("theme", "dark")
try:
    store.save("bad key", "x")
except ValueError as exc:
    print(exc)
print(store.data)
print([cls.__name__ for cls in SafeStorage.__mro__])
saving 'theme'
saving 'bad key'
invalid key: 'bad key'
{'theme': 'dark'}
['SafeStorage', 'LoggingMixin', 'ValidationMixin', 'Storage', 'object']

LoggingMixin has no base class besides object, yet it calls super().save(...). On its own that would fail, since object has no save. But mixins are never used alone. In SafeStorage's MRO, the class after LoggingMixin is ValidationMixin, and after that Storage. Each save does its part and hands off.

Order matters. Mixins go to the left of the main base class, because classes on the left come first in the MRO. Here logging runs before validation, so even the rejected key is logged. Swap them to class SafeStorage(ValidationMixin, LoggingMixin, Storage) and invalid keys are rejected before being logged. The main class (Storage) goes last, because it does the real work and ends the chain without calling super().save.

Mixin conventions that keep things manageable:

  • Name them with a Mixin suffix so their role is obvious.
  • Keep each one small and focused on a single behavior.
  • Don't give them state-heavy __init__ methods unless they follow the cooperative rules above.

Django's class-based views (LoginRequiredMixin) and the standard library's socketserver.ThreadingMixIn are well-known real-world examples of this pattern.

Subclassing Built-in Types

Inheriting from dict, list, or str works, but there's a trap. The built-ins are implemented in C, and their methods don't call each other through your overrides:

class LowerDict(dict):
    def __setitem__(self, key: str, value: object) -> None:
        super().__setitem__(key.lower(), value)


d = LowerDict()
d["Name"] = "Ada"
d.update({"Email": "ada@example.com"})
print(d)
print(LowerDict(City="London"))
{'name': 'Ada', 'Email': 'ada@example.com'}
{'City': 'London'}

Direct assignment went through the override, but update() and the constructor bypassed it. To customize dict-, list-, or str-like behavior reliably, inherit from collections.UserDict, UserList, or UserString instead. They're written in Python and route everything through the methods you override:

from collections import UserDict


class LowerUserDict(UserDict):
    def __setitem__(self, key: str, value: object) -> None:
        super().__setitem__(key.lower(), value)


u = LowerUserDict()
u["Name"] = "Ada"
u.update({"Email": "ada@example.com"})
print(u)
print(LowerUserDict(City="London"))
{'name': 'Ada', 'email': 'ada@example.com'}
{'city': 'London'}

Common Pitfalls

  • Calling the parent by name. Notifier.__init__(self, recipient) works in single inheritance, but in a multiple-inheritance hierarchy it skips the MRO, so some classes run twice and others never. Use super().
  • Breaking the chain. A method that overrides a cooperative method without calling super() silently cuts off every class after it in the MRO.
  • Mismatched signatures. If overrides of the same method take different positional arguments, super() calls fail depending on the final MRO. Keep signatures compatible, and prefer keyword arguments.
  • Deep hierarchies. Every level makes it harder to know which method actually runs. If you find yourself printing __mro__ to understand your own code, the design probably wants composition instead. Composition vs inheritance discusses that trade-off, and abstract base classes show how to define interfaces that subclasses must implement.

Conclusion

Inheritance gives a subclass everything its base classes define, and lets it override or extend any piece. The part that trips people up is super(), and it comes down to one rule: super() means "the next class in the MRO of the object I was called on", not "my parent". Python builds that MRO with C3 linearization, which keeps subclasses before their bases, respects the order you list bases in, and runs a shared ancestor only once. Write cooperative methods that always call super() and pass keyword arguments through, put mixins to the left of the main base class, and use UserDict and friends when you subclass built-in containers. With those rules, multiple inheritance becomes predictable.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading