When __init_subclass__ Goes Silent — A CPython MRO Edge Case

Python's __init_subclass__ hook silently fails when a metaclass mro() places Superclass before subclass (CPython bug #105038, reported by plokmijnuhby).…

The Bottom Line

Python’s __init_subclass__ is the cleanest way to hook subclass creation without a metaclass — unless a metaclass manipulates the Method Resolution Order (MRO). If the MRO places the defining class before the subclass being created, __init_subclass__ is silently skipped. This is a confirmed CPython bug (#105038) driven by how type.__new__ uses super() internally.


The Problem: A Hook That Never Fires

__init_subclass__ was introduced in Python 3.6 as a simpler alternative to metaclasses. When any class inherits from a class that defines __init_subclass__, that method is called automatically during class creation.

Consider this metaclass that produces a non-standard MRO:

class Superclass:
    def __init_subclass__(cls):
        print(f"Called __init_subclass__ on {cls.__name__}")
        cls.hook_was_called = True

class Metaclass(type):
    def mro(cls):
        return [Superclass, cls, object]

class WeirdSub(metaclass=Metaclass):
    pass

print(f"issubclass(WeirdSub, Superclass): {issubclass(WeirdSub, Superclass)}")
print(f"MRO: {[c.__name__ for c in WeirdSub.__mro__]}")
print(f"hook_was_called: {getattr(WeirdSub, 'hook_was_called', False)}")

Output:

issubclass(WeirdSub, Superclass): True
MRO: ['Superclass', 'WeirdSub', 'object']
hook_was_called: False

issubclass() returns True. The MRO contains Superclass. But __init_subclass__ was never invoked. The hook — the entire reason __init_subclass__ exists — is dead silent.


The Fix: Root Cause in type.__new__

The bug lives in CPython’s type.__new__ implementation. Here’s the relevant logic:

# Simplified version of what type.__new__ does internally
def type__new__(metacls, name, bases, namespace):
    # ... class creation logic ...

    # To avoid calling __init_subclass__ on the class being defined,
    # CPython uses super(Subclass, Subclass).__init_subclass__()
    # which skips everything before Subclass in the MRO!

    super(WeirdSub, WeirdSub).__init_subclass__()
    # ^^^ This resolves to object.__init_subclass__ — Superclass is skipped!

The call super(WeirdSub, WeirdSub).__init_subclass__() produces the MRO starting after WeirdSub. Since Metaclass.mro() placed Superclass before WeirdSub, Superclass is excluded from the search. The method never fires.

As reporter plokmijnuhby explained in CPython #105038:

“To avoid looking for and potentially calling an __init_subclass__ method defined by Subclass itself, currently type.__new__ instead calls super(Subclass, Subclass).__init_subclass__. This causes it to skip over Superclass in the MRO.”

“A possible fix would be to avoid calling super and instead read the whole MRO, ignoring Subclass if it finds it; however, this would require some thought about what happens if Superclass.__init_subclass__ itself calls super.”


Why It Matters

This isn’t just a curiosity — it has real implications:

  1. Framework design patterns: Libraries like Django, SQLAlchemy, and Pydantic use __init_subclass__ for registration, validation, or configuration hooks. A metaclass that reorders the MRO silently disables those hooks.

  2. Metaclass composition: Projects that mix multiple metaclasses (via __bases__ manipulation or custom mro()) can accidentally break __init_subclass__ without any visible error.

  3. Testing blind spots: issubclass() returns True, the MRO looks correct, and no exception is raised. The only symptom is missing behavior — a classic “it doesn’t work and I don’t know why” bug.

  4. Related bug chain: This same super() pattern in type.__new__ causes issue #83846, which produces a cryptic error when types don’t include themselves in their MRO at all.


Safe Alternatives

If you need subclass hooks that always fire, regardless of MRO ordering:

Metaclass __new__ always runs, and you control the call order explicitly:

class SafeMetaclass(type):
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        # Your hook here — always fires
        print(f"Metaclass hook on {cls.__name__}")
        return cls

Best for: Framework authors who control the class creation chain.

Option 2: Use __set_name__ on descriptors

If you’re tracking attribute definitions rather than classes, __set_name__ fires reliably during class creation regardless of MRO quirks:

class TrackedAttribute:
    def __set_name__(self, owner, name):
        print(f"Attribute {name} defined on {owner.__name__}")
        Registry.register(owner, name)

Best for: ORM field tracking, serializer registrations, validation descriptors.

Option 3: Manual MRO iteration (fix for metaclass authors)

If you control the metaclass and can’t switch away from __init_subclass__:

class FixedMetaclass(type):
    def mro(cls):
        return [Superclass, cls, object]

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        # Manually scan MRO for __init_subclass__
        for ancestor in cls.__mro__:
            if hasattr(ancestor, '__init_subclass__'):
                ancestor.__init_subclass__(cls)
        return cls

Best for: Bug fixes in existing metaclass code where you can’t refactor the entire class hierarchy.


Decision Guide

Approach Complexity Reliability Use Case
Metaclass __new__ Medium ✅ Always fires New code, framework design
__set_name__ Low ✅ Always fires Attribute-level hooks
Manual MRO iteration High ✅ Works around bug Patching existing code
__init_subclass__ alone Low ❌ Silent failure Only if MRO is never modified

How to Detect This Bug

Add this test to any project using __init_subclass__ with metaclasses:

def test_init_subclass_fires_with_metaclass():
    """Verify __init_subclass__ fires even with custom MRO."""
    hook_called = []

    class Base:
        def __init_subclass__(cls):
            hook_called.append(cls.__name__)

    class CustomMeta(type):
        def mro(cls):
            # Non-standard MRO that places Base before cls
            return [Base, cls, object]

    class Child(metaclass=CustomMeta):
        pass

    assert "Child" in hook_called, "__init_subclass__ was silently skipped!"

Key Takeaways

  1. __init_subclass__ is not guaranteed — it depends on MRO ordering through an internal super() call.
  2. Test with metaclasses — if your code mixes metaclasses and __init_subclass__, verify hooks actually fire.
  3. Prefer metaclass __new__ for framework code that must always run.
  4. Use __set_name__ for attribute-level hooks that need to be MRO-independent.
  5. The bug is open — tracked at CPython #105038 since 2023.

Rule of thumb: If you’re writing a metaclass that overrides mro(), test that __init_subclass__ hooks still fire. If you’re using a framework that mixes metaclasses and __init_subclass__, verify your subclass hooks are actually running — they may be silently swallowed.


This post was auto-generated. View all patches on GitHub.