Filter concepts by levelShowing all levels.

Python · Object-Oriented Python

Advanced OOP

Concepts
6

How Python resolves attribute lookups across multiple bases, reusable behaviour via mixins, the descriptor and attribute-access protocols underneath @property itself, and the two lowest-level customization hooks — __slots__ and metaclasses.

This section

Multiple inheritance done safely

How Python resolves attribute lookups across several bases at once, and reusable behaviour built on top of it.

MRO, multiple inheritance, and super()

coreadvanced

A class can inherit from more than one base — class C(A, B):. The MRO (Cls.__mro__) is the single lookup path Python computes across all of them. super() walks that path, letting every class in a chain cooperate correctly.

Think of it as

The MRO is a single-file line, not a family tree — class C(A, B): does not give C two separate parents to choose between; Python flattens A, B and every one of their own bases into one ordered line (C, A, B, ..., object) and super() always means 'the next name in this line,' never 'my literal base class.' Cooperative inheritance is every class in the line agreeing to call super() and pass the baton forward, rather than any one class assuming it's the last stop.

python
class C(A, B):
    def method(self):
        super().method()   # calls the NEXT class in C's MRO, not A specifically

What we're doing: Show cooperative inheritance working correctly across a diamond hierarchy — every __init__ calling super() so all three ancestor initializers run exactly once, in MRO order.

diamond.pypython
class Base:
    def __init__(self):
        print("Base init")
        self.base_ready = True


class Left(Base):
    def __init__(self):
        super().__init__()
        print("Left init")


class Right(Base):
    def __init__(self):
        super().__init__()
        print("Right init")


class Diamond(Left, Right):
    def __init__(self):
        super().__init__()
        print("Diamond init")


d = Diamond()
print(Diamond.__mro__)
print(d.base_ready)
3
super().__init__() in Left does not call Base directly — it calls whatever is next in Diamond's MRO, which turns out to be Right.
9
Right's own super().__init__() is what actually reaches Base — MRO ensures Base runs exactly once, not twice.
15
Diamond's __init__ starts the whole chain — cooperative inheritance means every class trusts the next super() call to happen.
Output
Base init
Right init
Left init
Diamond init
(<class '__main__.Diamond'>, <class '__main__.Left'>, <class '__main__.Right'>, <class '__main__.Base'>, <class 'object'>)
True

Why this works: Base init prints only once, not twice, even though both Left and Right inherit from Base — this is exactly what MRO-based cooperative inheritance guarantees: C3 linearization places Base after BOTH Left and Right in Diamond's MRO, so super() calls form a single chain (Diamond → Left → Right → Base) rather than two separate branches that would run Base's __init__ redundantly. Without every class calling super(), the chain would break at whichever __init__ forgot to continue it.

Calling a parent class directly instead of super(), breaking cooperative inheritance

Wrong

python
class Base:
    def __init__(self):
        print("Base init")

class Left(Base):
    def __init__(self):
        Base.__init__(self)   # hardcodes Base, bypassing the MRO
        print("Left init")

class Right(Base):
    def __init__(self):
        Base.__init__(self)   # ALSO hardcodes Base
        print("Right init")

class Diamond(Left, Right):
    def __init__(self):
        Left.__init__(self)
        Right.__init__(self)

Diamond()   # Base init prints TWICE

Better

python
class Left(Base):
    def __init__(self):
        super().__init__()   # follows the MRO — cooperative
        print("Left init")

class Right(Base):
    def __init__(self):
        super().__init__()   # also cooperative
        print("Right init")

class Diamond(Left, Right):
    def __init__(self):
        super().__init__()   # one call starts the whole chain

What you see: "Base init" prints twice for a single Diamond() call, and any setup Base performs (opening a resource, incrementing a counter) runs redundantly.

Why: Base.__init__(self) hardcodes exactly which class's __init__ runs next, completely bypassing the MRO — both Left and Right independently decide to call Base directly, so Base runs once per path instead of once total. super() instead asks 'who is next in THIS particular MRO,' which for a diamond hierarchy correctly resolves to a single shared ancestor being initialized exactly once, no matter how many subclasses lead to it.

super() follows the MRO, not "the" parent

class C(A, B)

multiple bases

C.__mro__

one linear order: C, A, B, object

super()

next name in that same order

  1. class C(A, B) — multiple bases
  2. C.__mro__ — one linear order: C, A, B, object
  3. super() — next name in that same order

What determines the MRO, and what super() does with it

What determines the MRO, and what super() does with it
ConceptWhat it means
class C(A, B):C has two direct bases — both are searched for attributes
C.__mro__the full, linear lookup order: (C, A, B, ..., object)
super()a proxy for "the next class after the current one in the MRO"
Cooperative inheritanceevery __init__ calls super().__init__(...), continuing the chain

Together

python
class A:
    def greet(self):
        return "A"

class B:
    def greet(self):
        return "B"

class C(A, B):
    pass

print(C.__mro__)
print(C().greet())

Remember: super() means "the next class in the MRO," never "my literal parent" — cooperative inheritance only works when every class in the chain calls it.

See also: inheritance · mixins · composition vs inheritance

Mixins

standardadvanced

A mixin is a small class designed to be combined via multiple inheritance — it adds one focused behaviour (logging, comparison, serialization) and is never meant to be instantiated alone. class Widget(LoggingMixin, Base): mixes it in.

Think of it as

A mixin is a topping, not a meal — LoggingMixin alone is not something anyone orders; it only makes sense combined with a real base class the way a topping only makes sense on a pizza. Multiple inheritance is what lets a class order several toppings (LoggableWidget(LoggingMixin, SerializableMixin, Widget)) at once, each contributing its own focused slice of behaviour.

python
class SomeMixin:
    def extra_behaviour(self):
        return "added by the mixin"

class Widget(SomeMixin, Base):
    pass   # Widget now has extra_behaviour(), plus everything Base defines

What we're doing: Combine two independent, focused mixins with a real base class, and show each mixin contributing its own behaviour without knowing about the other.

mixins.pypython
class ComparableMixin:
    def __eq__(self, other):
        return self.key() == other.key()

    def __lt__(self, other):
        return self.key() < other.key()


class SerializableMixin:
    def to_dict(self):
        return {"type": self.__class__.__name__, **self.__dict__}


class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def key(self):
        return self.price


class OrderedProduct(ComparableMixin, SerializableMixin, Product):
    pass


a = OrderedProduct("Book", 15)
b = OrderedProduct("Pen", 3)

print(a < b)
print(a.to_dict())
1
ComparableMixin only assumes self.key() exists somewhere in the MRO — it never mentions Product directly.
8
SerializableMixin is equally independent — it only reads self.__dict__, which works for any object.
22
OrderedProduct combines both mixins with the real Product base — neither mixin needed to know about the other to compose cleanly.
Output
False
{'type': 'OrderedProduct', 'name': 'Book', 'price': 15}

Why this works: a < b works because ComparableMixin.__lt__ calls self.key() and other.key(), both resolved through the MRO to Product.key — the mixin never needed to inherit from Product itself, only to be combined WITH something that provides key(). a.to_dict() works the same way, reading whatever __dict__ the combined object happens to have. Neither mixin references the other or Product by name, which is exactly what keeps them independently reusable.

Putting the real base class before the mixin, breaking the intended override order

Wrong

python
class LoggingMixin:
    def render(self):
        print("logging...")
        return super().render()

class Widget:
    def render(self):
        return "base render"

class LoggedWidget(Widget, LoggingMixin):   # WRONG ORDER
    pass

w = LoggedWidget()
print(w.render())   # no logging! Widget.render wins in the MRO

Better

python
class LoggedWidget(LoggingMixin, Widget):   # mixin FIRST
    pass

w = LoggedWidget()
print(w.render())   # logs, then calls Widget.render via super()

What you see: The mixin's behaviour never runs — no exception, just silently the wrong method wins according to the MRO.

Why: Python's MRO searches bases left to right (after C3 linearization) — class LoggedWidget(Widget, LoggingMixin): puts Widget's render before LoggingMixin's in the lookup order, so Widget.render is found first and LoggingMixin's version is never reached at all. Listing the mixin first is the entire mechanism by which its override takes priority.

A mixin adds one behaviour to a real base class

class Base

the real, standalone class

LoggingMixin

one focused behaviour, never used alone

class X(Mixin, Base)

combines both via multiple inheritance

  1. class Base — the real, standalone class
  2. LoggingMixin — one focused behaviour, never used alone
  3. class X(Mixin, Base) — combines both via multiple inheritance

What makes a class a mixin, by convention

What makes a class a mixin, by convention
TraitWhy it matters
Name ends in "Mixin"signals it is not meant to stand alone
No state of its own (usually)avoids __init__ conflicts when combined with other bases
One focused responsibilitycomposability — combine only the behaviours actually needed
Listed before the real base classclass Widget(LoggingMixin, Base): puts the mixin's overrides first in the MRO

Together

python
class LoggingMixin:
    def log(self, message):
        print(f"[{self.__class__.__name__}] {message}")

class Widget:
    def render(self):
        return "rendering"

class LoggedWidget(LoggingMixin, Widget):
    def render(self):
        self.log("about to render")
        return super().render()

w = LoggedWidget()
print(w.render())

Remember: A mixin adds one behaviour, never instantiated alone — list it before the real base class, and forward *args/**kwargs via super().__init__() if it has one.

See also: mro and multiple inheritance · composition vs inheritance · inheritance

Advertisement

The protocols behind attribute access

What @property is built from, and the lowest-level hooks behind every single attribute read and write.

Descriptors

standardadvanced

A descriptor is a class implementing __get__ (and optionally __set__/__delete__), assigned as a CLASS attribute on another class — Python calls those methods instead of a plain lookup. @property is built entirely from this protocol.

Think of it as

A descriptor is a smart badge reader on a door, not a doorknob — instead of an attribute being a fixed value sitting behind the door, accessing it triggers the badge reader's own logic every time. @property is one specific badge reader Python ships pre-built; writing a descriptor class directly is building a custom one, reusable across many different doors (classes) at once.

python
class Descriptor:
    def __get__(self, obj, owner):
        return ...

    def __set__(self, obj, value):
        ...

class Name:
    attr = Descriptor()   # assigned as a CLASS attribute

What we're doing: Build a reusable validating descriptor and attach it to two different classes, showing the same validation logic apply in both without being rewritten.

validated.pypython
class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = "_" + name

    def __get__(self, obj, owner):
        if obj is None:
            return self
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name[1:]} must be positive")
        setattr(obj, self.name, value)


class Product:
    price = PositiveNumber()

    def __init__(self, price):
        self.price = price


class Account:
    balance = PositiveNumber()

    def __init__(self, balance):
        self.balance = balance


p = Product(10)
a = Account(50)
print(p.price, a.balance)

try:
    p.price = -5
except ValueError as e:
    print("ValueError:", e)
1
__set_name__ runs once at class-creation time, telling the descriptor what name it was assigned to (price, balance) — so one descriptor class can back many differently-named attributes.
8
__get__ runs every time p.price is read — obj is None when accessed on the class itself (Product.price), which the check handles.
10
__set__ runs every time p.price = value is assigned — validation happens here, before anything is actually stored.
Output
10 50
ValueError: price must be positive

Why this works: p.price and a.balance both work identically because PositiveNumber is the same reusable descriptor class attached to two unrelated classes — neither Product nor Account had to reimplement the validation logic. p.price = -5 raises before -5 is ever stored, because __set__ runs the validation check first and only calls setattr(obj, self.name, value) if it passes — the exact same guarantee a hand-written @property setter would give, but written once and reused everywhere it is needed.

Assigning a descriptor as an instance attribute instead of a class attribute

Wrong

python
class Product:
    def __init__(self, price):
        self.price = PositiveNumber()   # WRONG — instance attribute
        self.price = price               # this just overwrites it with a plain int!

p = Product(10)
print(p.price)   # 10 — but __get__/__set__ never ran at all

Better

python
class Product:
    price = PositiveNumber()   # CLASS attribute — this is required

    def __init__(self, price):
        self.price = price

What you see: No error, but the descriptor's __get__/__set__ never run — the "validation" is silently absent, and price behaves like a completely ordinary attribute.

Why: The descriptor protocol is only invoked by Python's attribute-lookup machinery for CLASS-level attributes — self.price = PositiveNumber() inside __init__ just stores a PositiveNumber object as a normal instance attribute, and the very next line overwrites it with a plain int, since nothing ever triggers __get__/__set__. Descriptors must be defined at class-body level, exactly like PositiveNumber() sitting directly under class Product:, to actually intercept access.

A descriptor intercepts attribute access

obj.attr

looks like a plain attribute read

__get__(self, obj, owner)

runs instead, if attr is a descriptor

validated / computed

the descriptor decides what obj.attr returns

  1. obj.attr — looks like a plain attribute read
  2. __get__(self, obj, owner) — runs instead, if attr is a descriptor
  3. validated / computed — the descriptor decides what obj.attr returns

Data vs. non-data descriptors, and what wins the lookup

Data vs. non-data descriptors, and what wins the lookup
Descriptor typeInstance __dict__ vs. descriptor
Data descriptor (__get__ + __set__)descriptor always wins, even over instance __dict__
Non-data descriptor (__get__ only)instance __dict__ wins if the same name is set there
Not a descriptor at allplain attribute lookup — instance, then class
@propertya built-in data descriptor — this is how it enforces read-only

Together

python
class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = "_" + name

    def __get__(self, obj, owner):
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError("must be positive")
        setattr(obj, self.name, value)

class Product:
    price = PositiveNumber()

    def __init__(self, price):
        self.price = price

p = Product(10)
print(p.price)

Remember: A descriptor must be a CLASS attribute — a data descriptor always wins over instance __dict__. Store state on obj, never on the descriptor itself.

See also: property decorator · class attributes · slots

__getattribute__, __getattr__, and __setattr__

referenceadvanced

__getattribute__(self, name) intercepts EVERY attribute read, even ones that would succeed. __getattr__(self, name) fires only as a fallback, after lookup already failed. __setattr__(self, name, value) intercepts every write.

Think of it as

__getattribute__ is a guard checking every visitor at the front door, whether or not they have a key — nothing gets past without going through it, which is exactly why overriding it carelessly can break the object entirely. __getattr__ is a lost-and-found desk reached only after the front door search already failed to find anything — it never runs for an attribute that already exists.

python
class Name:
    def __getattr__(self, name):
        return f"missing: {name}"      # only for attributes that don't exist

    def __setattr__(self, name, value):
        super().__setattr__(name, value)   # must delegate, or recursion follows

What we're doing: Show __getattr__ only firing for a genuinely missing attribute, and __setattr__ intercepting every assignment including the ones __init__ itself makes.

proxy.pypython
class LoggedAttrs:
    def __getattr__(self, name):
        print(f"__getattr__: {name!r} not found normally")
        raise AttributeError(name)

    def __setattr__(self, name, value):
        print(f"__setattr__: {name!r} = {value!r}")
        super().__setattr__(name, value)


obj = LoggedAttrs()
obj.x = 1
print(obj.x)
try:
    print(obj.y)
except AttributeError:
    print("AttributeError raised, as __getattr__ chose to")
2
__getattr__ runs only for obj.y — obj.x is found in instance __dict__ first, so __getattr__ never sees it.
4
Re-raising AttributeError from inside __getattr__ is intentional here — it is the correct way to say "this really is missing."
9
__setattr__ runs for obj.x = 1 too — every write goes through it, with no fallback distinction the way reads have.
Output
__setattr__: 'x' = 1
1
__getattr__: 'y' not found normally
AttributeError raised, as __getattr__ chose to

Why this works: print(obj.x) never triggers __getattr__ at all — obj.x is genuinely present in instance __dict__ after obj.x = 1 ran, so normal lookup succeeds and the fallback is never reached. print(obj.y) is the opposite case: y was never set, normal lookup fails, and only THEN does __getattr__ run — printing its message and re-raising AttributeError, which is the correct way for __getattr__ to report a truly missing attribute rather than inventing a value.

Overriding __setattr__ without delegating, causing infinite recursion

Wrong

python
class Broken:
    def __setattr__(self, name, value):
        self.__dict__[name] = value   # looks safe, but self.__dict__ triggers __getattribute__...
        # actually this specific line is fine — the REAL trap is simpler:

class ReallyBroken:
    def __setattr__(self, name, value):
        setattr(self, name, value)   # calls itself — infinite recursion!

r = ReallyBroken()
r.x = 1   # RecursionError

Better

python
class Fixed:
    def __setattr__(self, name, value):
        print(f"setting {name}")
        super().__setattr__(name, value)   # delegates to object's default behaviour

f = Fixed()
f.x = 1   # works

What you see: RecursionError: maximum recursion depth exceeded — raised the moment any attribute is assigned on the class.

Why: setattr(self, name, value) inside __setattr__ is exactly the same call that triggered __setattr__ in the first place — calling it again re-enters the very method currently running, with no base case to stop the recursion. super().__setattr__(name, value) instead delegates to object's own default attribute-setting behaviour, which actually stores the value rather than re-triggering the override.

__getattribute__ always runs; __getattr__ only on failure

obj.x

__getattribute__ runs, always

found?

success — return it, __getattr__ skipped

not found

__getattr__ runs as a fallback

  1. obj.x — __getattribute__ runs, always
  2. found? — success — return it, __getattr__ skipped
  3. not found — __getattr__ runs as a fallback

When each hook actually runs

When each hook actually runs
HookRuns for
__getattribute__every single attribute read, unconditionally
__getattr__only when normal lookup already failed with AttributeError
__setattr__every single attribute write, unconditionally
Plain lookup (neither defined)instance __dict__, then class, following the MRO

Together

python
class Traced:
    def __getattr__(self, name):
        print(f"__getattr__ fallback for {name!r}")
        return f"default-{name}"

t = Traced()
t.x = 1
print(t.x)      # found normally — __getattr__ NOT called
print(t.y)      # missing — __getattr__ called

Remember: __getattribute__ runs for every read; __getattr__ only runs after lookup fails. Overriding __setattr__ without delegating to super() causes infinite recursion.

See also: descriptors · hasattr getattr setattr · encapsulation

Advertisement

The lowest-level customization hooks

Trading flexibility for memory, and controlling how a class itself gets built.

__slots__

standardadvanced

__slots__ = ("x", "y") declares a fixed set of attribute names an instance can hold, replacing per-instance __dict__ with lower-memory storage slots. Trades away arbitrary new attributes for less memory and slightly faster access.

Think of it as

A normal instance's __dict__ is a hotel room with a mini-fridge you can stock with anything, any time — __slots__ turns it into a room with built-in, labeled cubbies (x, y, and nothing else). Fewer surprises, less overhead per room, but a guest can no longer just leave a new item (attribute) wherever they like.

python
class Name:
    __slots__ = ("a", "b")   # ONLY these two attributes are allowed

    def __init__(self, a, b):
        self.a = a
        self.b = b

What we're doing: Compare memory use of a __slots__ class against an ordinary one, and show that assigning an unlisted attribute is caught immediately.

point.pypython
import sys


class SlottedPoint:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y


class PlainPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y


sp = SlottedPoint(1, 2)
pp = PlainPoint(1, 2)

print(sys.getsizeof(sp) < sys.getsizeof(pp) + sys.getsizeof(pp.__dict__))
print(hasattr(sp, "__dict__"))
print(hasattr(pp, "__dict__"))

try:
    sp.z = 3
except AttributeError as e:
    print("AttributeError:", e)
1
__slots__ = ("x", "y") declares the only two attribute names SlottedPoint instances can ever hold.
2
PlainPoint has no __slots__, so it keeps the default per-instance __dict__ — this is what the memory comparison measures.
Output
True
False
True
AttributeError: 'SlottedPoint' object has no attribute 'z' and no __dict__ for setting new attributes

Why this works: sys.getsizeof(sp) alone is smaller than PlainPoint's object PLUS its __dict__ combined, because SlottedPoint has no __dict__ at all to allocate — its two attributes live in fixed slots instead. hasattr(sp, '__dict__') is False for exactly that reason. sp.z = 3 raises immediately because z was never declared in __slots__ — Python enforces the fixed attribute set at assignment time, not just as documentation.

Adding __slots__ to a subclass while the base class still has a __dict__

Wrong

python
class Base:
    pass   # no __slots__ — has a __dict__

class Derived(Base):
    __slots__ = ("x",)   # looks restrictive, but isn't!

d = Derived()
d.x = 1
d.y = 2   # works anyway — Base's __dict__ leaks through!
print(d.__dict__)

Better

python
class Base:
    __slots__ = ()   # empty __slots__ — no __dict__ here either

class Derived(Base):
    __slots__ = ("x",)   # now genuinely restrictive

d = Derived()
d.x = 1
d.y = 2   # AttributeError, as intended

What you see: d.y = 2 succeeds even though Derived only declared __slots__ = ("x",) — the memory savings and attribute restriction both silently do not apply.

Why: A subclass only avoids a __dict__ if EVERY class in its MRO declares __slots__ — Base here declares none, so Python gives Derived instances a __dict__ anyway (inherited from Base), and __slots__ on Derived only adds two dedicated slots alongside that already-present __dict__. The fix is __slots__ = () on Base specifically, an empty tuple that opts the base class itself out of having a __dict__.

What __slots__ changes about a class

What __slots__ changes about a class
Without __slots__With __slots__
Any new attribute name can be assignedonly names listed in __slots__ are allowed
Each instance carries a __dict__no __dict__ — fixed-size storage instead
More memory per instanceless memory per instance, especially at scale
Subclass inherits flexibility automaticallya subclass needs its OWN __slots__ to keep the savings

Together

python
class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x, p.y)
try:
    p.z = 3
except AttributeError as e:
    print("AttributeError:", e)

Remember: __slots__ removes a __dict__ for a fixed attribute set — a subclass keeps that benefit only if every class in its MRO also declares __slots__.

See also: descriptors · class attributes · instance attributes

Metaclasses

referenceadvanced

A metaclass is the class of a class — type(SomeClass) is type by default, meaning type builds every ordinary class. class Name(metaclass=Meta): lets Meta control how Name itself gets built, the way a class controls its own instances.

Think of it as

If a class is a blueprint for building objects, a metaclass is the factory that builds blueprints — type is the default factory every class comes from, the same way object is the default ancestor every instance comes from. Reaching for a custom metaclass means changing how CLASSES themselves get constructed, one level of abstraction above changing how objects get constructed.

python
class Meta(type):
    def __new__(mcls, name, bases, namespace):
        return super().__new__(mcls, name, bases, namespace)

class Name(metaclass=Meta):
    pass

What we're doing: Write a small metaclass that automatically uppercases every class attribute name, showing the metaclass intercepting class construction itself, not instance construction.

meta.pypython
class UppercaseMeta(type):
    def __new__(mcls, name, bases, namespace):
        uppercased = {
            (key.upper() if not key.startswith("__") else key): value
            for key, value in namespace.items()
        }
        return super().__new__(mcls, name, bases, uppercased)


class Config(metaclass=UppercaseMeta):
    debug = True
    version = "1.0"


print(Config.DEBUG)
print(Config.VERSION)
print(hasattr(Config, "debug"))
1
UppercaseMeta subclasses type — this is what makes it a metaclass rather than an ordinary class.
2
__new__ runs when Config itself is being BUILT, not when a Config instance is created — namespace is Config's entire class body as a dict.
5
super().__new__(mcls, name, bases, uppercased) hands the modified namespace to type's own class-building machinery.
Output
True
1.0
False

Why this works: Config.DEBUG (uppercase) exists and Config.debug (lowercase) does not, because UppercaseMeta's __new__ ran once, at the moment class Config(metaclass=UppercaseMeta): was being defined — it rewrote every non-dunder name in the class body to uppercase before the class was even fully built. This happens exactly once, at class-creation time, completely separate from anything that happens later when Config() is called to create an instance.

Reaching for a metaclass when a simpler tool (a decorator, __init_subclass__) would do

Wrong

python
class RegisterMeta(type):
    registry = []
    def __new__(mcls, name, bases, namespace):
        cls = super().__new__(mcls, name, bases, namespace)
        RegisterMeta.registry.append(cls)
        return cls

class Plugin(metaclass=RegisterMeta):
    pass

Better

python
registry = []

class Plugin:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        registry.append(cls)   # same result, no metaclass needed

class MyPlugin(Plugin):
    pass

What you see: No error — both versions register subclasses correctly, but the metaclass version is meaningfully harder to read, and forces every user of Plugin to understand metaclasses just to see how registration works.

Why: __init_subclass__ (a regular classmethod hook, added specifically to cover many of the cases metaclasses used to be reached for) runs whenever a subclass is created — the same moment a metaclass's __new__ would run — without requiring readers to understand the metaclass machinery at all. A metaclass is genuinely necessary only for a narrower set of cases: changing how the class ITSELF behaves as an object (not just its instances), which __init_subclass__ cannot do.

A metaclass builds classes, the way a class builds instances

type

the default metaclass

class Point

built by type

p = Point()

an instance, built by Point

  1. type — the default metaclass
  2. class Point — built by type
  3. p = Point() — an instance, built by Point

The parallel between object construction and class construction

The parallel between object construction and class construction
LevelWhat builds it
An instance (p = Point(1, 2))built by its class, Point
A class (class Point: ...)built by its metaclass, type by default
type(p)Point — the class an instance belongs to
type(Point)type — the metaclass a class belongs to

Together

python
class Point:
    pass

p = Point()
print(type(p))
print(type(Point))
print(isinstance(Point, type))

Remember: A metaclass builds classes, the way a class builds instances — type is the default. Reach for __init_subclass__ if the goal is only "run code on subclassing."

See also: abc module · abstract base classes · mro and multiple inheritance

Advertisement