DEV Community

Cover image for The Secret Life of Python: super() and the Method Resolution Order
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Python: super() and the Method Resolution Order

Why super() doesn't mean "parent class" (and what it really does)


Timothy stared at his screen in complete bewilderment. He'd written what seemed like straightforward inheritance code, but the output made absolutely no sense.

class A:
    def process(self):
        print("A.process()")

class B(A):
    def process(self):
        print("B.process()")
        super().process()

class C(A):
    def process(self):
        print("C.process()")
        super().process()

class D(B, C):
    def process(self):
        print("D.process()")
        super().process()

d = D()
d.process()
Enter fullscreen mode Exit fullscreen mode

He expected the output to be:

D.process()
B.process()
A.process()
Enter fullscreen mode Exit fullscreen mode

After all, D inherits from B, and B inherits from A. That's how inheritance works, right?

But when he ran the code, he got:

D.process()
B.process()
C.process()
A.process()
Enter fullscreen mode Exit fullscreen mode

"What?!" Timothy exclaimed, catching Margaret's attention from across the library. "Why is C.process() being called? Class B doesn't inherit from C! When B calls super().process(), it should call A.process(), not C.process()!"

Margaret walked over, a knowing smile on her face. "Ah, you've discovered one of Python's most misunderstood features. You think super() means 'call my parent class,' don't you?"

"Of course it does! super() calls the superclass—the parent!"

"That," Margaret said gently, "is precisely what everyone thinks. And it's precisely wrong. super() doesn't call your parent class, Timothy. It calls the next class in the Method Resolution Order."

"The... what?"

"Come," Margaret said, opening a thick tome titled The Method Resolution Order: Python's Hidden Path. "Let me show you how Python really resolves methods."


The Method Resolution Order (MRO)

"Every class in Python," Margaret began, "has an attribute called __mro__—the Method Resolution Order. It's a tuple that defines the exact sequence Python follows when looking up attributes."

She typed:

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

print(D.__mro__)
Enter fullscreen mode Exit fullscreen mode

Output:

(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, 
 <class '__main__.A'>, <class 'object'>)
Enter fullscreen mode Exit fullscreen mode

"This tuple," Margaret explained, "is the order Python searches for methods and attributes. When you access d.process(), Python looks:

  1. First in D
  2. Then in B
  3. Then in C
  4. Then in A
  5. Finally in object (the root of all classes)

And here's the crucial part: super() doesn't mean 'my parent class'—it means 'the next class in the MRO.'"

"The MRO also affects other operations," Margaret added. "For instance, isinstance() and issubclass() walk the MRO:"

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

obj = D()

# isinstance() checks if the object's class is in the MRO
print(isinstance(obj, D))  # True - D is in D's MRO
print(isinstance(obj, B))  # True - B is in D's MRO
print(isinstance(obj, C))  # True - C is in D's MRO
print(isinstance(obj, A))  # True - A is in D's MRO
print(isinstance(obj, object))  # True - object is always in MRO

# issubclass() checks if one class appears after another in the MRO
print(issubclass(D, B))  # True
print(issubclass(D, C))  # True
print(issubclass(D, A))  # True

# The MRO makes these checks simple and efficient
print(D.__mro__)
# All the classes where isinstance returns True!
Enter fullscreen mode Exit fullscreen mode

Timothy studied the MRO carefully. "So when B.process() calls super().process(), it doesn't look at B's parent class A. It looks at the next class in the MRO after B, which is... C!"

"Exactly! Let's trace through your original example step by step:"

class A:
    def process(self):
        print("A.process()")

class B(A):
    def process(self):
        print("B.process()")
        super().process()  # Next in MRO after B

class C(A):
    def process(self):
        print("C.process()")
        super().process()  # Next in MRO after C

class D(B, C):
    def process(self):
        print("D.process()")
        super().process()  # Next in MRO after D

# The MRO is: D -> B -> C -> A -> object
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

d = D()
d.process()
Enter fullscreen mode Exit fullscreen mode

Margaret traced the execution:

1. d.process() starts in D
   - Prints "D.process()"
   - Calls super().process() → next in MRO is B

2. B.process() executes
   - Prints "B.process()"
   - Calls super().process() → next in MRO is C (not A!)

3. C.process() executes
   - Prints "C.process()"
   - Calls super().process() → next in MRO is A

4. A.process() executes
   - Prints "A.process()"
   - No super() call, done
Enter fullscreen mode Exit fullscreen mode

"The key insight," Margaret emphasized, "is that super() in B doesn't know or care that B inherits from A. It only knows the MRO of the instance being operated on—in this case, an instance of D."

Timothy's eyes widened. "So the same method B.process() will call different next methods depending on what class the instance actually is?"

"Precisely! Watch:"

# Same classes as before

# Instance of B directly
b = B()
print("MRO of B:", B.__mro__)
# (<class 'B'>, <class 'A'>, <class 'object'>)
b.process()
# B.process()
# A.process()

# Instance of D
d = D()
print("\nMRO of D:", D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
d.process()
# D.process()
# B.process()
# C.process()
# A.process()
Enter fullscreen mode Exit fullscreen mode

"When you call b.process() on a B instance, the MRO is B -> A -> object, so super() in B calls A. But when you call d.process() on a D instance, the MRO is D -> B -> C -> A -> object, so super() in B calls C!"


How Python Builds the MRO: C3 Linearization

"But how does Python determine this order?" Timothy asked. "Why is the MRO D -> B -> C -> A and not some other sequence?"

Margaret pulled out a diagram. "Python uses an algorithm called C3 Linearization (also called C3 superclass linearization). It has three main rules:

  1. Children before parents (local precedence order)
  2. Left-to-right ordering (from the inheritance list)
  3. Monotonicity (preserve order from parent MROs)

The monotonicity rule is crucial: if class A appears before class B in any parent's MRO, then A must appear before B in the child's MRO (unless explicitly overridden in the child's direct inheritance list).

Let me show you how it builds the MRO for class D(B, C):"

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass
Enter fullscreen mode Exit fullscreen mode

Margaret wrote out the algorithm:

Step 1: Start with the class itself
  L(D) = D + merge(L(B), L(C), [B, C])

Step 2: Expand the parent linearizations
  L(B) = B, A, object
  L(C) = C, A, object
  L(D) = D + merge([B, A, object], [C, A, object], [B, C])

Step 3: Apply merge algorithm
  - Take the first head that doesn't appear in any other tail
  - "Head" = first element of a list
  - "Tail" = everything after the first element

  merge([B, A, object], [C, A, object], [B, C]):
    First heads: B, C, B
    B is not in any tail → take B

  D, B + merge([A, object], [C, A, object], [C]):
    First heads: A, C, C
    A is in tail of [C, A, object] → skip
    C is not in any tail → take C

  D, B, C + merge([A, object], [A, object]):
    First heads: A, A
    A is not in any tail → take A

  D, B, C, A + merge([object], [object]):
    First head: object
    object is not in any tail → take object

  Final: D, B, C, A, object
Enter fullscreen mode Exit fullscreen mode

"This algorithm ensures several important properties," Margaret explained:

# Property 1: A class always comes before its parents
class Parent:
    pass

class Child(Parent):
    pass

print(Child.__mro__)
# (<class 'Child'>, <class 'Parent'>, <class 'object'>)
# Child before Parent ✓

# Property 2: Parents are in the order specified
class A:
    pass

class B:
    pass

class C(A, B):  # A before B
    pass

print(C.__mro__)
# (<class 'C'>, <class 'A'>, <class 'B'>, <class 'object'>)
# A appears before B ✓

# Property 3: A class's MRO is consistent with its parents' MROs
class D(B, A):  # B before A
    pass

print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'A'>, <class 'object'>)
Enter fullscreen mode Exit fullscreen mode

"But what happens," Timothy asked, "if you create an inheritance hierarchy that violates these rules?"

Margaret smiled. "Excellent question. Python refuses to create such classes:"

class X:
    pass

class Y(X):
    pass

class Z(X, Y):  # Try to put X before Y, but Y inherits from X
    pass

# TypeError: Cannot create a consistent method resolution
# order (MRO) for bases X, Y
Enter fullscreen mode Exit fullscreen mode

"The algorithm says 'X must come before Y' (from the Z(X, Y) declaration), but also 'Y must come before X' (because Y inherits from X). This is impossible, so Python raises an error."

"Here's another violation of monotonicity:"

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

# D's MRO: D -> B -> C -> A
# This respects that B.MRO has (B, A) and C.MRO has (C, A)

# But what if we try:
class E(C, B):
    pass

# E's MRO: E -> C -> B -> A
# This also works! Order reversed but still consistent

# However, this breaks:
class F(A, B):  # Try to put A before B, but B inherits from A
    pass

# TypeError: Cannot create a consistent method resolution
# order (MRO) for bases A, B
#
# We're saying "A before B" but B's MRO says "B before A"
# Inconsistent!
Enter fullscreen mode Exit fullscreen mode

"The C3 algorithm detects these inconsistencies and prevents you from creating impossible hierarchies."


The Diamond Problem

"Now let's look at why the MRO matters so much," Margaret said, drawing a diagram on her slate:

    A
   / \
  B   C
   \ /
    D
Enter fullscreen mode Exit fullscreen mode

"This is called the diamond problem. Class D inherits from both B and C, which both inherit from A. The question is: when D calls a method that all classes implement, what order are they called in?"

class A:
    def __init__(self):
        print("A.__init__")

class B(A):
    def __init__(self):
        print("B.__init__")
        super().__init__()

class C(A):
    def __init__(self):
        print("C.__init__")
        super().__init__()

class D(B, C):
    def __init__(self):
        print("D.__init__")
        super().__init__()

d = D()
# D.__init__
# B.__init__
# C.__init__
# A.__init__

print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
Enter fullscreen mode Exit fullscreen mode

"Notice something crucial," Margaret pointed out. "Even though both B and C inherit from A, A.__init__ is only called once. This is by design. The MRO ensures that every class appears exactly once, and super() ensures we follow the MRO correctly."

"What if we don't use super()?" Timothy asked.

class A:
    def __init__(self):
        print("A.__init__")

class B(A):
    def __init__(self):
        print("B.__init__")
        A.__init__(self)  # Direct call instead of super()

class C(A):
    def __init__(self):
        print("C.__init__")
        A.__init__(self)  # Direct call instead of super()

class D(B, C):
    def __init__(self):
        print("D.__init__")
        B.__init__(self)
        C.__init__(self)

d = D()
# D.__init__
# B.__init__
# A.__init__
# C.__init__
# A.__init__  ← Called twice!
Enter fullscreen mode Exit fullscreen mode

"Disaster!" Margaret exclaimed. "A.__init__ is called twice. This might seem harmless in this example, but imagine if A was opening a file, creating a database connection, or allocating resources. You'd have duplicated resources and potential corruption."

"The MRO plus super() solves this by ensuring each class is called exactly once, in a predictable order."


Cooperative Multiple Inheritance

"The real power of super() and the MRO," Margaret explained, "emerges when you design classes to work cooperatively. This requires a specific pattern."

She wrote out an example:

class LoggingMixin:
    def __init__(self, **kwargs):
        print(f"LoggingMixin.__init__")
        super().__init__(**kwargs)  # Pass remaining kwargs along

class ValidationMixin:
    def __init__(self, **kwargs):
        print(f"ValidationMixin.__init__")
        super().__init__(**kwargs)

class Base:
    def __init__(self, name):
        print(f"Base.__init__: name={name}")
        self.name = name

class Document(LoggingMixin, ValidationMixin, Base):
    def __init__(self, name, **kwargs):
        print(f"Document.__init__")
        super().__init__(name=name, **kwargs)

doc = Document("report.pdf", extra="value")
# Document.__init__
# LoggingMixin.__init__
# ValidationMixin.__init__
# Base.__init__: name=report.pdf

print(Document.__mro__)
# (<class 'Document'>, <class 'LoggingMixin'>, 
#  <class 'ValidationMixin'>, <class 'Base'>, <class 'object'>)
Enter fullscreen mode Exit fullscreen mode

"Notice several critical patterns here," Margaret noted:

Pattern 1: Accept and pass **kwargs

"Every class except the final base class accepts **kwargs and passes them to super(). This allows arguments to flow through the entire MRO chain."

Pattern 2: Extract only what you need

class TimestampMixin:
    def __init__(self, timestamp=None, **kwargs):
        self.timestamp = timestamp or datetime.now()
        super().__init__(**kwargs)  # Pass the rest along

class AuthorMixin:
    def __init__(self, author=None, **kwargs):
        self.author = author or "Unknown"
        super().__init__(**kwargs)

class Base:
    def __init__(self, name):
        self.name = name

class Article(TimestampMixin, AuthorMixin, Base):
    def __init__(self, name, **kwargs):
        super().__init__(name=name, **kwargs)

article = Article(
    name="Python Deep Dive",
    author="Margaret",
    timestamp=datetime(2024, 1, 1)
)
print(f"{article.name} by {article.author} at {article.timestamp}")
Enter fullscreen mode Exit fullscreen mode

"Each mixin extracts its parameters from **kwargs and passes the rest along. The chain continues until all parameters are consumed."

Pattern 3: Always call super()

"Every class in the chain must call super().__init__(), even if it doesn't think it has a parent class. Remember, super() follows the MRO of the instance, not the class hierarchy you see in the code."

class Mixin:
    def __init__(self, **kwargs):
        print("Mixin.__init__")
        # Even though Mixin doesn't explicitly inherit from anything,
        # we still call super() to continue the MRO chain
        super().__init__(**kwargs)

class Base:
    def __init__(self, value):
        print(f"Base.__init__: value={value}")
        self.value = value

class Combined(Mixin, Base):
    def __init__(self, value, **kwargs):
        super().__init__(value=value, **kwargs)

obj = Combined(value=42)
# Mixin.__init__
# Base.__init__: value=42
Enter fullscreen mode Exit fullscreen mode

Timothy frowned. "But what if I mix classes that use super() with classes that don't?"

"Chaos," Margaret said flatly.

class A:
    def __init__(self):
        print("A.__init__")

class B(A):
    def __init__(self):
        print("B.__init__")
        A.__init__(self)  # Direct call - breaks the chain!

class C(A):
    def __init__(self):
        print("C.__init__")
        super().__init__()  # Uses super()

class D(B, C):
    def __init__(self):
        print("D.__init__")
        super().__init__()

d = D()
# D.__init__
# B.__init__
# A.__init__  ← Chain stops here! C is never called
Enter fullscreen mode Exit fullscreen mode

"Because B calls A directly instead of using super(), the MRO chain is broken. C.__init__ is never called, even though it's in the MRO. The rule is: either all classes in a hierarchy use super(), or none of them do."


super() Without Arguments (Python 3 Magic)

"You may have noticed," Margaret said, "that we've been writing super() without any arguments. In Python 2, you had to write super(CurrentClass, self). How does Python 3 know what class and instance to use?"

Timothy thought about it. "Some kind of... magic?"

"Close," Margaret smiled. "It's a clever use of Python's closure mechanism. When a class is created, Python binds a special closure variable called __class__ to any method that uses super()."

class Example:
    def method(self):
        print(super())
        # Python transforms this to:
        # print(super(__class__, self))
        # where __class__ is bound at class creation time

# Let's peek at the closure
import inspect

print(Example.method.__code__.co_freevars)
# ('__class__',)

# The method has a closure that captures the class
print(Example.method.__closure__)
# (<cell at 0x...: type object at 0x...>,)
Enter fullscreen mode Exit fullscreen mode

"When you call super() with no arguments, Python:

  1. Looks for __class__ in the function's closure (bound at class creation)
  2. Gets self (or cls for classmethods) from the local scope
  3. Effectively calls super(__class__, self)

The __class__ reference is bound when the class is created, not at call time, which is why it works even with renamed classes:"

class Original:
    def method(self):
        return super()

# Rename the class
Renamed = Original

obj = Renamed()
print(obj.method())
# <super: <class 'Original'>, <Renamed object>>
# Still knows the original class name!

# Even if we delete the original name binding
Temp = Original
del Original
obj = Temp()
obj.method()  # Still works! __class__ was bound at class creation
Enter fullscreen mode Exit fullscreen mode

"But there are cases where you still need explicit arguments:"

class A:
    def method(self):
        return "A.method"

class B(A):
    def method(self):
        return "B.method"

class C(B):
    def method(self):
        return "C.method"

    def skip_parent(self):
        """Skip B and go directly to A in the MRO"""
        # Normal super() would call B.method
        result_b = super().method()  # Calls B.method

        # But we can explicitly skip B by using super(B, self)
        # This starts the search AFTER B in the MRO
        result_a = super(B, self).method()  # Calls A.method

        return f"Normal: {result_b}, Skipped: {result_a}"

obj = C()
print(obj.skip_parent())
# Normal: B.method, Skipped: A.method

# Another case: calling super() outside a method
class D(A):
    # This doesn't work - no __class__ closure
    # external_super = super()  # RuntimeError: super(): no arguments

    def method(self):
        # But this works - inside a method with __class__ closure
        return super()

# Also useful: super() in a function that's assigned to a class later
def external_method(self):
    # No __class__ closure here
    # return super().method()  # RuntimeError

    # Must use explicit form
    return super(D, self).method()

D.external = external_method
Enter fullscreen mode Exit fullscreen mode

Real-World Pattern: Mixins

"Let's look at how real frameworks use multiple inheritance and the MRO," Margaret said, opening a Django models reference.

from datetime import datetime

# Mixin classes provide discrete functionality
class TimestampMixin:
    """Adds created_at and updated_at fields"""
    def __init__(self, **kwargs):
        self.created_at = datetime.now()
        self.updated_at = datetime.now()
        super().__init__(**kwargs)

    def touch(self):
        """Update the updated_at timestamp"""
        self.updated_at = datetime.now()

class SoftDeleteMixin:
    """Adds soft delete functionality"""
    def __init__(self, **kwargs):
        self.is_deleted = False
        self.deleted_at = None
        super().__init__(**kwargs)

    def delete(self):
        """Soft delete - mark as deleted instead of removing"""
        self.is_deleted = True
        self.deleted_at = datetime.now()

    def restore(self):
        """Restore a soft-deleted record"""
        self.is_deleted = False
        self.deleted_at = None

class AuditMixin:
    """Adds audit trail functionality"""
    def __init__(self, **kwargs):
        self.created_by = kwargs.pop('created_by', None)
        self.updated_by = kwargs.pop('updated_by', None)
        super().__init__(**kwargs)

    def update_audit(self, user):
        """Update audit information"""
        self.updated_by = user
        if hasattr(self, 'touch'):
            self.touch()

# Base model class
class Model:
    """Base class for all models"""
    def __init__(self, id=None):
        self.id = id

    def save(self):
        print(f"Saving {self.__class__.__name__} with id={self.id}")

# Combine mixins to create feature-rich models
class BlogPost(AuditMixin, TimestampMixin, SoftDeleteMixin, Model):
    """A blog post with full audit trail, timestamps, and soft delete"""
    def __init__(self, id, title, content, **kwargs):
        self.title = title
        self.content = content
        super().__init__(id=id, **kwargs)

# Create a blog post
post = BlogPost(
    id=1,
    title="Understanding super()",
    content="It's all about the MRO!",
    created_by="Margaret"
)

print(f"Post: {post.title}")
print(f"Created: {post.created_at}")
print(f"By: {post.created_by}")
print(f"Deleted: {post.is_deleted}")

# Use mixin functionality
post.delete()
print(f"After delete - Deleted: {post.is_deleted}")

post.restore()
print(f"After restore - Deleted: {post.is_deleted}")

# Check the MRO
print("\nMRO:")
for cls in BlogPost.__mro__:
    print(f"  {cls.__name__}")
# BlogPost
# AuditMixin
# TimestampMixin
# SoftDeleteMixin
# Model
# object
Enter fullscreen mode Exit fullscreen mode

"This pattern is everywhere in Django," Margaret explained. "Mixins let you compose functionality without deep inheritance hierarchies. Each mixin is focused on one concern, and you combine them as needed."


Common Pitfalls and Debugging

"Let me show you the most common mistakes," Margaret said, opening a chapter titled "When MRO Goes Wrong."

Pitfall 1: Inconsistent Method Signatures

class A:
    def process(self, data):
        print(f"A: {data}")

class B(A):
    def process(self, data, extra):  # Different signature!
        print(f"B: {data}, {extra}")
        super().process(data)

class C(A):
    def process(self, data):
        print(f"C: {data}")
        super().process(data)

class D(B, C):
    def process(self, data, extra):
        super().process(data, extra)

# d = D()
# d.process("test", "extra")  # TypeError!
# C.process() doesn't accept 'extra' parameter
Enter fullscreen mode Exit fullscreen mode

"Solution: Use **kwargs to handle varying signatures:"

class A:
    def process(self, data, **kwargs):
        print(f"A: {data}")

class B(A):
    def process(self, data, extra=None, **kwargs):
        print(f"B: {data}, {extra}")
        super().process(data, **kwargs)

class C(A):
    def process(self, data, **kwargs):
        print(f"C: {data}")
        super().process(data, **kwargs)

class D(B, C):
    def process(self, data, extra=None, **kwargs):
        super().process(data, extra=extra, **kwargs)

d = D()
d.process("test", extra="value")
# B: test, value
# C: test
# A: test
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Calling super() in Some but Not All Methods

class Mixin1:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)  # Good - calls super()
        self.mixin1_setup = True

class Mixin2:
    def __init__(self, **kwargs):
        # Bad - no super() call!
        self.mixin2_setup = True

class Base:
    def __init__(self):
        self.base_setup = True

class Combined(Mixin1, Mixin2, Base):
    def __init__(self):
        super().__init__()

obj = Combined()
print(hasattr(obj, 'mixin1_setup'))  # True
print(hasattr(obj, 'mixin2_setup'))  # True
print(hasattr(obj, 'base_setup'))    # False - Base.__init__ never called!
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Assuming Parent Class Relationship

class Payment:
    def process(self):
        print("Processing payment...")
        return True

class CreditCardPayment(Payment):
    def process(self):
        print("Validating credit card...")
        # Assumption: super() will call Payment.process()
        return super().process()

class SecurePayment:
    def process(self):
        print("Applying security checks...")
        return True  # Oops, doesn't call super()!

class SecureCreditCard(CreditCardPayment, SecurePayment):
    pass

# What's the MRO?
print(SecureCreditCard.__mro__)
# SecureCreditCard -> CreditCardPayment -> SecurePayment -> Payment -> object

payment = SecureCreditCard()
result = payment.process()
# Validating credit card...
# Applying security checks...
# (Payment.process() is never called!)
Enter fullscreen mode Exit fullscreen mode

"The problem is SecurePayment.process() doesn't call super(). The chain stops there."

Debugging Tool: Visualizing the MRO

def print_mro(cls, indent=0):
    """Print the MRO as a tree"""
    print("  " * indent + cls.__name__)

def trace_super_calls(cls):
    """Show the complete super() chain for a class"""
    print(f"super() chain for {cls.__name__}:")
    for i, klass in enumerate(cls.__mro__):
        print(f"  {i}. {klass.__name__}")
        if hasattr(klass, '__init__'):
            # Check if __init__ calls super()
            import inspect
            source = inspect.getsource(klass.__init__)
            has_super = 'super()' in source
            print(f"     __init__ calls super(): {has_super}")

# Example usage
class A:
    def __init__(self):
        super().__init__()

class B(A):
    def __init__(self):
        super().__init__()

class C(A):
    def __init__(self):
        super().__init__()

class D(B, C):
    def __init__(self):
        super().__init__()

trace_super_calls(D)
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Multiple Inheritance

"Multiple inheritance is powerful," Margaret cautioned, "but it's not always the right tool."

Use multiple inheritance when:

  • You need to compose orthogonal behaviors (mixins)
  • The mixins have no overlapping attributes
  • All classes cooperate with super()
  • The MRO makes intuitive sense

Avoid multiple inheritance when:

  • Classes have conflicting method implementations
  • You're inheriting from multiple concrete classes (not mixins)
  • The inheritance hierarchy is unclear
  • Composition would be simpler
# Instead of this:
class FileLogger:
    def log(self, msg):
        with open('log.txt', 'a') as f:
            f.write(msg)

class ConsoleLogger:
    def log(self, msg):
        print(msg)

class DualLogger(FileLogger, ConsoleLogger):  # Conflict!
    pass

# Consider this:
class Logger:
    def __init__(self, *loggers):
        self.loggers = loggers

    def log(self, msg):
        for logger in self.loggers:
            logger.log(msg)

file_logger = FileLogger()
console_logger = ConsoleLogger()
dual_logger = Logger(file_logger, console_logger)
Enter fullscreen mode Exit fullscreen mode

"Composition often beats inheritance, even with super() and MRO."


Advanced Pattern: Abstract Base Classes with MRO

"One more advanced pattern," Margaret said, pulling out a final example. "Abstract base classes combined with mixins create powerful, type-safe designs:"

from abc import ABC, abstractmethod

class StorageBackend(ABC):
    """Abstract base class defining storage interface"""
    @abstractmethod
    def save(self, key, value):
        pass

    @abstractmethod
    def load(self, key):
        pass

# Cannot instantiate abstract class
try:
    storage = StorageBackend()
except TypeError as e:
    print(f"Error: {e}")
    # Error: Can't instantiate abstract class StorageBackend with abstract methods load, save

class CompressionMixin:
    """Mixin that adds compression to storage"""
    def save(self, key, value, **kwargs):
        compressed = self._compress(value)
        return super().save(key, compressed, **kwargs)

    def load(self, key, **kwargs):
        compressed = super().load(key, **kwargs)
        return self._decompress(compressed)

    def _compress(self, data):
        return f"compressed({data})"

    def _decompress(self, data):
        return data.replace("compressed(", "").rstrip(")")

class EncryptionMixin:
    """Mixin that adds encryption to storage"""
    def save(self, key, value, **kwargs):
        encrypted = self._encrypt(value)
        return super().save(key, encrypted, **kwargs)

    def load(self, key, **kwargs):
        encrypted = super().load(key, **kwargs)
        return self._decrypt(encrypted)

    def _encrypt(self, data):
        return f"encrypted({data})"

    def _decrypt(self, data):
        return data.replace("encrypted(", "").rstrip(")")

class DictStorage(StorageBackend):
    """Concrete storage implementation using dict"""
    def __init__(self):
        self.storage = {}

    def save(self, key, value):
        print(f"DictStorage.save: {key} = {value}")
        self.storage[key] = value

    def load(self, key):
        value = self.storage[key]
        print(f"DictStorage.load: {key} = {value}")
        return value

# Now DictStorage can be instantiated - it implements all abstract methods
storage = DictStorage()  # Works!

# Compose features with mixins
class SecureStorage(EncryptionMixin, CompressionMixin, DictStorage):
    """Storage with both encryption and compression"""
    pass

# Check the MRO
print("MRO:", [cls.__name__ for cls in SecureStorage.__mro__])
# ['SecureStorage', 'EncryptionMixin', 'CompressionMixin', 'DictStorage', 
#  'StorageBackend', 'ABC', 'object']

# Use it
storage = SecureStorage()
storage.save("key1", "secret data")
# DictStorage.save: key1 = encrypted(compressed(secret data))

loaded = storage.load("key1")
# DictStorage.load: key1 = encrypted(compressed(secret data))
print(f"Loaded: {loaded}")
# Loaded: secret data
Enter fullscreen mode Exit fullscreen mode

"Notice how the data flows through the MRO:

Saving:

  1. EncryptionMixin.save() encrypts → calls super()
  2. CompressionMixin.save() compresses → calls super()
  3. DictStorage.save() stores the encrypted+compressed data

Loading:

  1. DictStorage.load() retrieves encrypted+compressed data
  2. CompressionMixin.load() decompresses → returns to caller
  3. EncryptionMixin.load() decrypts → returns to caller

Each mixin adds its behavior in both directions. This is the power of cooperative multiple inheritance!"


Conclusion: The MRO Mastery

Timothy closed his notebook, his earlier confusion replaced by understanding. "So super() doesn't mean 'my parent class'—it means 'next in the MRO.' And the MRO is computed by C3 linearization to ensure a consistent, predictable order."

"Precisely," Margaret nodded. "Key principles to remember:

  1. Every class has an MRO determined by C3 linearization
  2. super() follows the MRO of the instance, not the class hierarchy
  3. Always call super() to continue the chain (in cooperative hierarchies)
  4. Use **kwargs to pass arguments through the chain
  5. Mixins should be orthogonal and focused on single concerns
  6. Check the MRO with cls.__mro__ when debugging
  7. isinstance() and issubclass() also walk the MRO

The MRO enables Python's multiple inheritance to work reliably. Without it, diamond inheritance would be chaos. With it, you can compose complex behavior from simple mixins."

"One last thing," Margaret added. "super() also interacts with descriptors and properties in interesting ways. When you call super() from within a property getter, you need to be careful about recursion. But that's an advanced topic that builds on both the descriptor protocol and the MRO."

Timothy looked at his original confusing example with fresh eyes:

class D(B, C):
    def process(self):
        super().process()  # Not "parent" - it's "next in D's MRO"

# When called on a D instance, the MRO is:
# D -> B -> C -> A -> object
# So super() in each class calls the NEXT one in THIS order
Enter fullscreen mode Exit fullscreen mode

"It all makes sense now. The behavior isn't mysterious—it's following a clear, predictable algorithm."

"Exactly," Margaret smiled. "And now you understand one of Python's most sophisticated features. Speaking of sophisticated features," she added with a glint in her eye, "have you ever wondered how classes themselves are created? How class syntax actually works?"

Timothy's eyes widened. "Are you talking about... metaclasses?"

"Indeed, Timothy. But that," Margaret said, standing and returning the tome to its shelf, "is a story for another day. A much deeper dive into Python's object model."

"After multiple inheritance and the MRO," Timothy said with a grin, "I think I'm ready for anything."

Margaret laughed. "We shall see, Timothy. We shall see..."


Key Takeaways

  1. super() calls the next class in the MRO, not the parent class
  2. The MRO is computed using C3 linearization with consistent, predictable rules
  3. Every class appears exactly once in the MRO
  4. Cooperative multiple inheritance requires calling super() in every method
  5. Use **kwargs to pass parameters through the inheritance chain
  6. Mixins enable composition of orthogonal behaviors
  7. Python 3's super() automatically determines class and instance
  8. Debug MRO issues with cls.__mro__ and cls.mro()
  9. Avoid multiple inheritance when composition is clearer
  10. All classes in a cooperative hierarchy must consistently use super()

Next in The Secret Life of Python: "Metaclasses: Classes That Make Classes"


Discussion Questions

  1. When is multiple inheritance appropriate vs. composition?
  2. How do popular frameworks (Django, Flask) use mixins?
  3. What are the performance implications of deep MRO chains?
  4. How does super() work with properties and descriptors?
  5. Can you create an MRO that violates the rules? What happens?

Share your MRO adventures and multiple inheritance patterns in the comments!


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)