- __init_subclass__ provides automatic class registration without metaclass complexity, available since Python 3.6.
- The hook runs at class definition time, catching configuration errors immediately rather than at runtime.
- Always include **kwargs and super().__init_subclass__(**kwargs) to support multiple inheritance scenarios.
- Performance overhead is about 12% per class creation—roughly 6 microseconds, negligible in practice.
- Use metaclasses only when you need __new__ control, dynamic namespace modification, or metaclass composition.
Why Your Plugin System Is Probably Overengineered
Metaclasses get a lot of hype in Python. Every “advanced Python” tutorial eventually builds some elaborate class factory with __new__ and __init__ hooks, leaving you with code that nobody (including future-you) can debug. But since Python 3.6, there’s been a simpler way to build self-registering plugin systems that most developers overlook: __init_subclass__.
I’ve written about metaclasses for validation performance gains, and they do have their place. But for the common case of “I want subclasses to automatically register themselves,” metaclasses are overkill. __init_subclass__ does the job in about 10 lines of code.

What init_subclass Actually Does
When Python creates a new class that inherits from your base class, it calls __init_subclass__ on the parent. That’s it. No metaclass magic, no type() weirdness, no mysterious __prepare__ hooks.
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
name = plugin_name or cls.__name__.lower()
Plugin._registry[name] = cls
print(f"Registered plugin: {name}")
class JSONExporter(Plugin, plugin_name="json"):
def export(self, data):
return json.dumps(data)
class XMLExporter(Plugin, plugin_name="xml"):
def export(self, data):
return f"<data>{data}</data>"
Run this, and you’ll see:
Registered plugin: json
Registered plugin: xml
No decorators. No explicit registration calls. Just define a subclass and it’s registered.
The Hook Signature Matters More Than You’d Think
Here’s something that tripped me up initially. The **kwargs in the signature isn’t optional fluff—it’s critical for multiple inheritance scenarios. If you forget it, you’ll get this cryptic error when combining your plugin class with certain mixins:
TypeError: __init_subclass__() takes 1 positional argument but 2 were given
The super().__init_subclass__(**kwargs) call passes any extra keyword arguments up the MRO chain. Skip it, and your class won’t play nice with other classes that also use __init_subclass__.
Building a Real Plugin System: Command Handlers
Let me show a more realistic example. Suppose you’re building a CLI tool where each command is a separate class:
from abc import ABC, abstractmethod
import time
class CommandHandler(ABC):
_commands = {}
_aliases = {}
def __init_subclass__(cls, command=None, aliases=None, **kwargs):
super().__init_subclass__(**kwargs)
if command is None:
# Skip registration for intermediate abstract classes
if not getattr(cls, '__abstractmethods__', set()):
raise ValueError(f"{cls.__name__} must specify command=...")
return
if command in CommandHandler._commands:
existing = CommandHandler._commands[command]
raise ValueError(
f"Command '{command}' already registered to {existing.__name__}"
)
CommandHandler._commands[command] = cls
for alias in (aliases or []):
if alias in CommandHandler._aliases:
raise ValueError(f"Alias '{alias}' conflicts with existing alias")
CommandHandler._aliases[alias] = command
@abstractmethod
def execute(self, args):
pass
@classmethod
def get_handler(cls, name):
# Check aliases first, then commands
resolved = cls._aliases.get(name, name)
handler_cls = cls._commands.get(resolved)
if handler_cls is None:
available = list(cls._commands.keys())
raise KeyError(f"Unknown command '{name}'. Available: {available}")
return handler_cls()
class ListFilesCommand(CommandHandler, command="ls", aliases=["list", "dir"]):
def execute(self, args):
import os
return os.listdir(args.get("path", "."))
class RemoveCommand(CommandHandler, command="rm", aliases=["remove", "delete"]):
def execute(self, args):
target = args.get("target")
if not target:
raise ValueError("target is required")
return f"Would remove: {target}"
Now watch what happens when we try to register a conflicting command:
# This will raise ValueError at class definition time, not runtime
class AnotherListCommand(CommandHandler, command="ls"):
def execute(self, args):
return "different ls"
# ValueError: Command 'ls' already registered to ListFilesCommand
The registration happens when Python parses the class definition. You catch configuration errors immediately, not when some user triggers the right code path three months later.
Performance: Is the Hook Overhead Meaningful?
I was curious whether __init_subclass__ adds measurable overhead to class creation. Let’s benchmark it against a plain class hierarchy:
import timeit
class PlainBase:
pass
class HookBase:
_registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
HookBase._registry[cls.__name__] = cls
def create_plain_classes(n):
for i in range(n):
type(f"Plain{i}", (PlainBase,), {})
def create_hook_classes(n):
for i in range(n):
type(f"Hook{i}", (HookBase,), {})
# Benchmark with 1000 classes
plain_time = timeit.timeit(lambda: create_plain_classes(1000), number=10)
hook_time = timeit.timeit(lambda: create_hook_classes(1000), number=10)
print(f"Plain classes: {plain_time:.4f}s")
print(f"With __init_subclass__: {hook_time:.4f}s")
print(f"Overhead: {((hook_time - plain_time) / plain_time) * 100:.1f}%")
On my M1 MacBook with Python 3.12:
Plain classes: 0.0521s
With __init_subclass__: 0.0587s
Overhead: 12.7%
Twelve percent overhead sounds scary until you realize we’re talking about 6 microseconds per class. If you’re creating 1000 plugin classes, you’ve got bigger problems than hook overhead.
The Metaclass Comparison You’re Probably Wondering About
Here’s the equivalent functionality using a metaclass:
class PluginMeta(type):
_registry = {}
def __new__(mcs, name, bases, namespace, plugin_name=None, **kwargs):
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
if plugin_name is not None:
PluginMeta._registry[plugin_name] = cls
elif name != 'PluginBase': # Skip the base class itself
PluginMeta._registry[name.lower()] = cls
return cls
class PluginBase(metaclass=PluginMeta):
pass
class JSONPlugin(PluginBase, plugin_name="json"):
pass
It works, but now you’re dealing with type.__new__ semantics, you need to understand the difference between __new__ and __init__ on metaclasses (which is different from regular classes), and if someone wants to mix your plugin with another metaclass-based system, they’re in for a world of pain.
The metaclass approach is about 40% more code and 400% more cognitive overhead. For simple registration patterns, __init_subclass__ wins.

Gotcha: Import Order and Circular Dependencies
One thing that caught me off guard: if your plugin modules have circular imports, the registration order becomes unpredictable. Consider this structure:
plugins/
__init__.py
base.py # Contains Plugin base class
json.py # JSONPlugin, imports xml.py for some reason
xml.py # XMLPlugin, imports json.py
If json.py imports xml.py at the top of the file, XMLPlugin might get registered before JSONPlugin, even though you imported json first in your main script.
This usually doesn’t matter, but if your registration logic depends on order (say, the first registered plugin becomes the default), you’ll get intermittent bugs that depend on import order.
My fix: don’t put dependencies between plugin modules, or use lazy imports inside methods rather than at module level.
Advanced Pattern: Validation at Registration Time
You can do more than just register classes. Here’s a pattern that validates plugin implementations:
import inspect
from typing import get_type_hints
class Validator:
_validators = {}
def __init_subclass__(cls, validates=None, **kwargs):
super().__init_subclass__(**kwargs)
if validates is None:
return
# Ensure the validate method has correct signature
if not hasattr(cls, 'validate'):
raise TypeError(f"{cls.__name__} must implement validate() method")
sig = inspect.signature(cls.validate)
params = list(sig.parameters.keys())
if params != ['self', 'value']:
raise TypeError(
f"{cls.__name__}.validate() must have signature (self, value), "
f"got {params}"
)
# Check return type annotation if present
hints = get_type_hints(cls.validate) if hasattr(cls.validate, '__annotations__') else {}
if 'return' in hints and hints['return'] != bool:
raise TypeError(
f"{cls.__name__}.validate() should return bool, "
f"annotated as {hints['return']}"
)
Validator._validators[validates] = cls
class EmailValidator(Validator, validates="email"):
def validate(self, value) -> bool:
return "@" in value and "." in value
# This would raise TypeError at import time:
# class BrokenValidator(Validator, validates="broken"):
# def validate(self, x, y): # Wrong signature!
# return True
The validation happens when Python loads the module, not when someone actually uses the validator. Errors surface immediately.
Combining with set_name for Descriptor Plugins
Here’s a pattern I don’t see discussed often: using __init_subclass__ alongside __set_name__ to create self-registering field descriptors.
class FieldRegistry:
_field_types = {}
def __init_subclass__(cls, type_name=None, **kwargs):
super().__init_subclass__(**kwargs)
if type_name:
FieldRegistry._field_types[type_name] = cls
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_field_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
validated = self.validate(value)
setattr(obj, self.private_name, validated)
def validate(self, value):
return value # Override in subclasses
class IntField(FieldRegistry, type_name="int"):
def __init__(self, min_val=None, max_val=None):
self.min_val = min_val
self.max_val = max_val
def validate(self, value):
value = int(value)
if self.min_val is not None and value < self.min_val:
raise ValueError(f"{self.name} must be >= {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.name} must be <= {self.max_val}")
return value
class StringField(FieldRegistry, type_name="string"):
def __init__(self, max_length=None):
self.max_length = max_length
def validate(self, value):
value = str(value)
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.name} exceeds max length {self.max_length}")
return value
# Usage
class User:
age = IntField(min_val=0, max_val=150)
name = StringField(max_length=100)
def __init__(self, name, age):
self.name = name
self.age = age
print(FieldRegistry._field_types)
# {'int': <class 'IntField'>, 'string': <class 'StringField'>}
user = User("Alice", 30)
print(user.age) # 30
user.age = "not a number" # ValueError: invalid literal for int()
The field types register themselves, and you get both descriptor behavior and a registry for introspection or serialization.
When init_subclass Isn’t Enough
There are legitimate cases where you need metaclasses:
-
Controlling
__new__behavior: If you need to modify how class instances are created (singleton patterns, object pools), you need metaclass__call__. -
Dynamic attribute injection:
__init_subclass__runs after the class is created. If you need to modify the class namespace before class creation, you need__prepare__and__new__. -
Multiple inheritance with other metaclasses: If you need to combine behaviors from two metaclass-based systems, you’ll need to create a combined metaclass.
__init_subclass__doesn’t help here.
But for “I want subclasses to register themselves automatically”? __init_subclass__ every time.
A Complete Plugin System in 50 Lines
Here’s a production-ready plugin loader that I’d actually use:
import importlib
import pkgutil
from pathlib import Path
from abc import ABC, abstractmethod
class PluginBase(ABC):
_plugins = {}
_loaded = False
def __init_subclass__(cls, plugin_id=None, **kwargs):
super().__init_subclass__(**kwargs)
# Skip abstract intermediate classes
if getattr(cls, '__abstractmethods__', set()):
return
pid = plugin_id or cls.__name__
if pid in PluginBase._plugins:
# Don't error on reimport (important for testing/reloading)
if PluginBase._plugins[pid] is not cls:
raise ValueError(f"Duplicate plugin ID: {pid}")
PluginBase._plugins[pid] = cls
@classmethod
def load_plugins(cls, package_path):
"""Discover and load all plugin modules from a directory."""
if cls._loaded:
return
package_dir = Path(package_path)
for module_info in pkgutil.iter_modules([str(package_dir)]):
if module_info.name.startswith('_'):
continue
try:
importlib.import_module(f"plugins.{module_info.name}")
except Exception as e:
# Log but don't crash—one bad plugin shouldn't break everything
print(f"Warning: Failed to load plugin {module_info.name}: {e}")
cls._loaded = True
@classmethod
def get(cls, plugin_id):
return cls._plugins.get(plugin_id)
@classmethod
def all_plugins(cls):
return dict(cls._plugins)
@abstractmethod
def execute(self, *args, **kwargs):
pass
Drop your plugin files in a plugins/ directory, call PluginBase.load_plugins("./plugins"), and they’re all registered. No configuration files, no decorators, no explicit registration.
If you’re debugging plugin systems at 2 AM, a good mechanical keyboard makes the frustration slightly more tactile.
Python Version Notes
__init_subclass__ was introduced in Python 3.6 (PEP 487). If you’re stuck on Python 3.5 or earlier (please upgrade), you’ll need metaclasses.
One subtle change: in Python 3.6-3.8, the super() call in __init_subclass__ was required but often forgotten without immediate errors. Python 3.9+ made the super call more consistently necessary when there’s multiple inheritance involved. Always include it.
How the Registration Order Works Mathematically
If you’re curious about the execution order in complex inheritance hierarchies, Python follows the Method Resolution Order (MRO) which uses the C3 linearization algorithm. For a class inheriting from parents , the MRO is computed as:
The __init_subclass__ hook is called in MRO order, meaning if you have:
class A(Plugin): pass
class B(A): pass
class C(B): pass
The hooks fire in order: Plugin.__init_subclass__ for , then for , then for . Each class’s registration completes before its subclass is processed.
FAQ
Q: Can I unregister a plugin at runtime?
Yes, just delete it from the registry dictionary. But be careful—any code that already has a reference to the class won’t be affected. You’re removing it from future lookups, not destroying the class. For testing, I usually create a fresh registry by resetting _plugins = {} in a pytest fixture.
Q: Does init_subclass work with dataclasses?
Yes, but with a caveat. The @dataclass decorator transforms the class after __init_subclass__ runs. If your hook needs to inspect dataclass fields, you won’t see them yet. Use dataclasses.fields(cls) in your plugin’s __post_init__ or a separate initialization step instead.
Q: How do I handle plugin dependencies (plugin A requires plugin B)?
Keep a separate dependency graph and resolve it after all plugins are loaded, not during __init_subclass__. The hook runs during import, and import order isn’t guaranteed. Add a requires = ["other_plugin"] class attribute and validate dependencies in a separate validate_dependencies() class method called after loading.
Use __init_subclass__ for plugin registration. Save metaclasses for when you actually need them (which is rarely). The Python ecosystem moved this direction for good reason—PEP 487 was specifically designed to handle the common case without metaclass complexity.
One thing I haven’t tested thoroughly: how this interacts with Python 3.13’s new type parameter syntax. My guess is it works fine since the underlying class creation machinery is the same, but if you’re on the bleeding edge and hit issues, I’d be curious to hear about it.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,793 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (759 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (649 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (549 views)