Metaclasses — when do you really need them?
Covers classes that build classes. The identity of type, the division with __init_subclass__, cases that class decorators solve, and the narrow areas where you really need a metaclass.
If Chapter 16 descriptors and __set_name__ covered the tool for turning attributes into objects, this chapter covers the tool for treating the class itself as an object — metaclasses. They are often mentioned as a symbol of “advanced Python,” but in real code they should be rare. This chapter explains what metaclasses are and how far the lighter alternatives can take you.
The core message of this chapter is: “90% of what metaclasses solve is solved by the class decorators of Chapter 12 decorator patterns or by __init_subclass__ from Chapter 15 magic methods.” The purpose of this chapter is to identify the narrow cases where a real metaclass is needed.
Starting point — classes are also objects #
In Python, classes are objects too.
class User:
pass
print(type(User)) # <class 'type'>
print(User.__class__) # <class 'type'>The type of the User class is type. That is, User is an instance of type. Every class is built as an instance of some type (metaclass). The default metaclass is type.
Building classes dynamically with type
#
def greet(self):
print(f"hi, {self.name}")
# equivalent: class User: ...
User = type("User", (object,), {
"name": "default",
"greet": greet,
})
u = User()
u.greet() # hi, defaulttype(name, bases, namespace) is the function that builds a class dynamically. In fact, the class User: syntax calls this internally.
This is a rarely used feature, but it shows what a metaclass really is — a metaclass is, in the end, a subclass of type.
Defining a metaclass #
class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"building class: {name}")
return super().__new__(mcs, name, bases, namespace)
class User(metaclass=Meta):
pass
# output: building class: Usermetaclass=Meta specifies which metaclass to build with. Meta.__new__ is called at the moment the class is defined.
__init_subclass__ — when a metaclass isn’t needed
#
__init_subclass__, seen briefly in Chapter 15, solves almost everything metaclasses do — without a metaclass.
class Plugin:
registry = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry.append(cls)
class JsonPlugin(Plugin): pass
class CsvPlugin(Plugin): pass
print(Plugin.registry)
# [<class 'JsonPlugin'>, <class 'CsvPlugin'>]Doing the same with a metaclass:
class PluginMeta(type):
registry = []
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
if name != "Plugin":
PluginMeta.registry.append(cls)
class Plugin(metaclass=PluginMeta): pass
class JsonPlugin(Plugin): pass
class CsvPlugin(Plugin): passLonger, with special cases like if name != "Plugin" to skip registering the base class itself. For ordinary auto-registration, __init_subclass__ is simpler.
Class decorator — another alternative #
The class decorator from Chapter 12 decorator patterns also handles much of what a metaclass would do.
def register(cls):
Plugin.registry.append(cls)
return cls
class Plugin:
registry = []
@register
class JsonPlugin(Plugin): pass
@register
class CsvPlugin(Plugin): passThe advantage is explicitness. Explicit registration is often better than implicit registration.
So when do you need a metaclass? #
A metaclass fits best in these cases.
1) When you want to change the behavior of type itself
#
Checks like isinstance and issubclass are decided by the metaclass. ABC (abc.ABCMeta) is an example.
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, data): ...
class FileStorage(Storage):
def save(self, data): ...
class BadStorage(Storage):
pass
s = FileStorage() # OK
b = BadStorage() # ✗ TypeError: Can't instantiate abstract classBlocking instantiation of classes that still carry @abstractmethod happens at the metaclass level. This kind of case is hard or impossible to solve with __init_subclass__.
2) DSL — class definition as new syntax #
class User(Model):
id = IntegerField(primary_key=True)
name = StringField(max_length=50)What the metaclass does in this kind of code:
- Collect which fields are defined
- Decide the table name
- Build a
_metastyle metadata object - Auto-add validation methods
Django ORM, SQLAlchemy, Peewee and others use this pattern. When you want to use the class definition as a DSL, a metaclass is needed. Chapter 25 DB integration — SQLAlchemy 2.x + Alembic ORM models are exactly that spot.
3) Consistent behavior under multiple inheritance #
When multiple base classes have different metaclasses, conflicts arise. When you want to control all behavior at a level above the metaclass.
This rarely appears in regular code. It’s an area for library authors.
Metaclass vs other tools — decision guide #
| Job | First try |
|---|---|
| Auto-register subclasses | __init_subclass__ |
| Add behavior to one class (eq, repr, etc.) | class decorator (@dataclass, etc.) |
| Intercept attribute access | descriptor |
| Add an instance method/attribute | regular method/attribute |
Change isinstance / issubclass behavior | metaclass |
| Use the base class definition itself as a DSL | metaclass |
| Validate syntax at class definition time | metaclass or __init_subclass__ |
Decision criterion: if __init_subclass__ or a class decorator solves it, that approach is almost always lighter. Choose metaclasses only for what those tools cannot solve.
What __init_subclass__ can do
#
Seeing how much this hook can handle shows why metaclasses are shrinking.
Auto-registration #
class Handler:
handlers = {}
def __init_subclass__(cls, *, route=None, **kwargs):
super().__init_subclass__(**kwargs)
if route:
Handler.handlers[route] = cls
class HomeHandler(Handler, route="/"): pass
class AboutHandler(Handler, route="/about"): pass
print(Handler.handlers)
# {'/': <class 'HomeHandler'>, '/about': <class 'AboutHandler'>}A lesser-known fact is that you can pass arguments like route="/" in the subclass’s definition. __init_subclass__ receives those arguments.
Force required methods #
class Service:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
for method in ("start", "stop"):
if not hasattr(cls, method):
raise TypeError(f"{cls.__name__} requires {method}")
class Good(Service):
def start(self): ...
def stop(self): ...
class Bad(Service): # ✗ TypeError
def start(self): ...A lighter enforcement than abc.ABC — you can define the job yourself.
Class variable validation #
class Form:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "name"):
raise TypeError(f"{cls.__name__}.name required")
if not isinstance(cls.name, str):
raise TypeError("name must be str")
class LoginForm(Form):
name = "login"__class_getitem__ — generic syntax hook
#
The hook that enables notation like MyClass[int].
class Box:
def __class_getitem__(cls, item):
return f"Box[{item.__name__}]"
print(Box[int]) # Box[int]Built-in generics like list[int], dict[str, int] are built on this hook. For making user classes generic (the style before Python 3.12’s new class Foo[T]: syntax), this hook did the job. New code uses the class Stack[T]: syntax from Chapter 9 typing in earnest.
type.__call__ — instance creation flow
#
When User() runs:
type(User).__call__(User)is called (the metaclass’s__call__)- Inside it,
User.__new__(User)creates the instance User.__init__(instance)initializes it
To intercept this flow, override the metaclass’s __call__. Comes up in singletons, instance caching, etc.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Logger(metaclass=SingletonMeta):
def __init__(self):
self.history = []
a = Logger()
b = Logger()
print(a is b) # TrueYou can do this with just __new__, but there’s the trap of __init__ being called twice — the metaclass version is sometimes safer.
Metaclass conflicts #
The subclass’s metaclass must be a subtype of every parent’s metaclass.
class MetaA(type): pass
class MetaB(type): pass
class A(metaclass=MetaA): pass
class B(metaclass=MetaB): pass
class C(A, B): pass
# TypeError: metaclass conflictThe headache you meet when multiply-inheriting from classes in different libraries — which is why even library authors use metaclasses carefully.
Exercises #
- Build an auto-registry using
__init_subclass__. Have subclass definitions ofclass Handler:take a keyword argument likeroute="/path"and register onHandler.routes: dict[str, type]. Verify the behavior with two subclassesHomeHandler,AboutHandler. - Build required-method enforcement with
__init_subclass__. If a subclass lacks thestart/stopmethods, raiseTypeErrorat definition time. - Implement the singleton pattern with a metaclass
SingletonMeta. Verify that a second call on the same class returns the first instance. Then implement the same thing using only__new__and compare the traps of both styles (especially whether__init__is called each time).
In one line: Classes are objects too; the type of a class is
type(the default metaclass). A metaclass is a subclass oftype. 90%+ of ordinary metaclass work is solved by__init_subclass__or class decorators. Real metaclasses are needed in narrow areas like ABC, ORM DSLs, and multi-inheritance consistency. Metaclass conflicts are frequent, so avoiding them when possible is best.
Next chapter #
Next, Chapter 18 async in depth — event loop, gather/wait, async generator covers the next step from Chapter 14 async intro on asyncio — the actual behavior of the event loop, the fine differences between gather / wait, async generators, and concurrency patterns.