Modern Python Advanced #3: Metaclasses — when do you really need them?

3 min read

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 = object
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 #

Dynamic class creation 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, default

type(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 #

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: 클래스 만드는 중: User

metaclass=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.

Auto-registration — __init_subclass__
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:

Same job — 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): pass

Longer, 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.

With a class decorator
def register(cls):
    Plugin.registry.append(cls)
    return cls

class Plugin:
    registry = []

@register
class JsonPlugin(Plugin): pass

@register
class CsvPlugin(Plugin): pass

The 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.

ABC = metaclass
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 class

Blocking 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 #

ORM model — user perspective
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 #

JobFirst try
Auto-register subclasses__init_subclass__
Add behavior to one class (eq, repr, etc.)class decorator (@dataclass, etc.)
Intercept attribute accessdescriptor
Add instance method/attributeregular method/attribute
Change isinstance/issubclass behaviormetaclass
Use base class definition as a DSLmetaclass
Validate at class definition timemetaclass 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 #

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 #

Force abstract 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 #

Field 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_getitem__
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:

  1. type(User).__call__(User) is called (the metaclass’s __call__)
  2. Inside, User.__new__(User) builds the instance
  3. User.__init__(instance) initializes

To intercept this flow, override __call__ on the metaclass. Common in singletons and instance caching.

Singleton — metaclass version
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)   # True

Possible 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.

🚫 Conflict
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 conflict

A 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 with class 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__ for Cls[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.

X