Modern Python Advanced #3: Metaclasses — when do you really need them?
If #2 Descriptors showed how to objectify attributes, this post is about treating classes themselves as objects — metaclasses. Often cited as the hallmark of “advanced Python,” but the right answer is almost always “don’t use them”. We cover what a metaclass is and how far lighter alternatives can take you.
Starting point — classes are objects too #
In Python, classes are objects too.
class User:
pass
print(type(User)) # <class 'type'>
print(User.__class__) # <class 'type'>The type of User is type. That is, User is an instance of type. Every class is built as an instance of some type (a metaclass). The default metaclass is type.
Building a class dynamically with type
#
def greet(self):
print(f"hi, {self.name}")
# Equivalent to: class User: ...
User = type("User", (object,), {
"name": "default",
"greet": greet,
})
u = User()
u.greet() # hi, defaulttype(name, bases, namespace) is the function that creates classes dynamically. The class User: syntax actually calls this internally.
This is rarely used, but it shows what a metaclass is — a metaclass is just a subclass of type.
Defining a metaclass #
class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"클래스 만드는 중: {name}")
return super().__new__(mcs, name, bases, namespace)
class User(metaclass=Meta):
pass
# Output: 클래스 만드는 중: Usermetaclass=Meta specifies which metaclass to use. The moment the class is defined, Meta.__new__ is called.
__init_subclass__ — when you don’t need a metaclass
#
The __init_subclass__ we briefly saw in #1 solves almost every metaclass job 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'>]The same job 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 awkwardness like if name != "Plugin" to avoid registering the base class itself. For typical auto-registration, __init_subclass__ is the answer.
Class decorators — another alternative #
The class decorators from Intermediate #5 also do a large fraction of metaclass jobs.
def register(cls):
Plugin.registry.append(cls)
return cls
class Plugin:
registry = []
@register
class JsonPlugin(Plugin): pass
@register
class CsvPlugin(Plugin): passThe advantage is being explicit. Explicit registration is often better than implicit.
Then when do you use metaclasses? #
Metaclasses fit best in the following cases.
1) When you want to change type’s behavior itself
#
isinstance and issubclass checks are decided by metaclasses. ABC (abc.ABCMeta) is the 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 with @abstractmethod happens at the metaclass level. Hard or impossible to solve with __init_subclass__.
2) DSLs — class definitions as new syntax #
class User(Model):
id = IntegerField(primary_key=True)
name = StringField(max_length=50)What metaclasses do here:
- Collect which fields are defined
- Decide the table name
- Build a metadata object like
_meta - Auto-add validation methods
Django ORM, SQLAlchemy, and Peewee use this pattern. When you treat class definitions as a kind of DSL, you need a metaclass.
3) Consistent behavior across multiple inheritance #
If multiple base classes have different metaclasses, conflicts happen. You resolve them by controlling everything one level above, at the metaclass.
This is rare in regular code. Library author territory.
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 instance method/attribute | regular method/attribute |
Change isinstance/issubclass behavior | metaclass |
| Use base class definition as a DSL | metaclass |
| Validate at class definition time | metaclass or __init_subclass__ |
Decision rule: If __init_subclass__ or a class decorator solves it, that’s almost always the lighter answer. Metaclasses are for when those can’t.
How capable __init_subclass__ is
#
Seeing how much this hook can handle shows why metaclass usage is 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: subclass definitions can accept keyword arguments like route="/", which __init_subclass__ receives.
Forcing 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__} 는 {method} 가 필요")
class Good(Service):
def start(self): ...
def stop(self): ...
class Bad(Service): # ✗ TypeError
def start(self): ...A lighter enforcement than abc.ABC — you define the rules 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 필요")
if not isinstance(cls.name, str):
raise TypeError("name 은 str")
class LoginForm(Form):
name = "login"__class_getitem__ — generic syntax hook
#
The hook enabling 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] and dict[str, int] are built on this hook. Before the new class Foo[T]: syntax in Python 3.12, this hook did the work for user-defined generics.
type.__call__ — instance creation flow
#
When User() runs:
type(User).__call__(User)is called (the metaclass’s__call__)- Inside,
User.__new__(User)builds the instance User.__init__(instance)initializes
To intercept this flow, override __call__ on the metaclass. Common in singletons and instance caching.
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) # TruePossible with __new__ alone too, but it has the __init__ called twice trap — sometimes the metaclass approach is safer.
Metaclass conflicts #
A 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 conflictA headache when multi-inheriting classes from various libraries — that’s why even library authors use metaclasses cautiously.
Wrap-up #
What this post covered:
- Classes are objects, the type of a class is
type(default metaclass) - Build a class dynamically with
type(name, bases, namespace) - Metaclass = subclass of
type; specify withclass X(metaclass=Meta): __init_subclass__(3.6+) solves over 90% of typical metaclass jobs- Class decorators are also a strong alternative
- Metaclasses are for narrow cases — ABC, ORM DSLs, multi-inheritance consistency
__init_subclass__accepts arguments —class Sub(Base, route="/x"):__class_getitem__forCls[T]notation- Metaclasses cause conflicts often — avoid when possible
In the next post (#4 Async in depth) we cover the next step from asyncio introduced in Intermediate #7 — event loop behavior, the subtle differences between gather/wait, async generators, and concurrency patterns.