backend / python oop / 10_metaclasses_in_depth.md

Metaclasses — Production Patterns

6 interview angles 6 min read source

Metaclasses — Production Patterns

A class is an instance of a metaclass (type by default). Metaclasses customize how classes themselves are created, before the first instance ever exists. Django ORM, SQLAlchemy, pydantic, abc — all use metaclasses to make their magic feel native.

The interview question: “When would you actually use one?” The honest answer: rarely; __init_subclass__ covers most cases. But knowing both is the senior signal.

The mental model

class Foo:
    pass

# Equivalent to (conceptually):
Foo = type("Foo", (), {})       # name, bases, dict

type is a class. Its instances are other classes. When Python parses a class statement, it calls type(name, bases, namespace) to build the class object.

A metaclass is a class whose instances are classes. You define one by subclassing type:

class TracedMeta(type):
    def __new__(mcs, name, bases, namespace):
        print(f"Creating class {name}")
        return super().__new__(mcs, name, bases, namespace)

class MyClass(metaclass=TracedMeta):
    pass
# Prints: "Creating class MyClass"

__new__ on a metaclass intercepts class creation. __init__ on a metaclass intercepts post-creation customization.

What metaclasses can do

1. Register subclasses automatically (Django models)

class ModelMeta(type):
    registry = {}

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if name != "Model":   # don't register the base
            ModelMeta.registry[name] = cls
        return cls

class Model(metaclass=ModelMeta):
    pass

class User(Model):
    pass

class Order(Model):
    pass

print(ModelMeta.registry)
# {'User': <class 'User'>, 'Order': <class 'Order'>}

Django uses this pattern: every class Foo(models.Model) gets registered with the ORM, gets a _meta attribute, gets a Manager, gets a default __init__ from declared fields. The user writes simple declarative code; the metaclass does the wiring.

2. Validate or enforce class structure

class InterfaceMeta(type):
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if bases:  # not the base class itself
            for attr in mcs.required:
                if not hasattr(cls, attr):
                    raise TypeError(f"{name} missing required {attr}")
        return cls

class Repository(metaclass=InterfaceMeta):
    required = ["save", "find", "delete"]

Any class with Repository ancestor must define save, find, delete — checked at class definition, not at instance time. Loud failure at import.

3. Inject methods / attributes

class AddReprMeta(type):
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        cls.__repr__ = lambda self: f"<{name} at {id(self)}>"
        return cls

Decorate-like behavior without an explicit decorator. Used historically by libraries pre-@dataclass.

4. Singleton

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 Database(metaclass=SingletonMeta):
    def __init__(self, url):
        self.url = url

a = Database("postgres://...")
b = Database("postgres://...")
assert a is b

The metaclass’s __call__ intercepts Database(...) — the class itself acting as a callable — and routes through a cache. There are simpler ways (@functools.cache-style); this is the metaclass-y way.

__init_subclass__ — the modern alternative

Python 3.6+ added __init_subclass__, which covers 90% of metaclass use cases without needing a metaclass:

class Model:
    registry = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Model.registry[cls.__name__] = cls

class User(Model):
    pass

class Order(Model):
    pass

print(Model.registry)
# {'User': <class 'User'>, 'Order': <class 'Order'>}

Same behavior, no metaclass needed. The base class hooks into “subclass was created” without changing the metaclass.

When __init_subclass__ is enough

  • Registry pattern.
  • Validate the subclass has required attrs.
  • Inject methods into subclasses.
  • Pass kwargs to subclasses via class Foo(Base, kwarg=value):.

When you still need a metaclass

  • You want to customize the namespace (the class body’s local scope). __init_subclass__ runs after the namespace is built; metaclass __new__ can mutate it.
  • You need to override __call__ on the class (controlling instance creation).
  • You need to integrate with multiple inheritance where the bases have different metaclasses — Python requires a common metaclass.
  • You need to control behavior of the class itself (not just its subclasses).

Django still uses metaclasses for models.Model partly for historical reasons, partly for __call__ customization (the Model.__init__ accepts kwargs derived from fields declared in the namespace).

Pydantic v2 — the modern approach

Pydantic v2 uses metaclass + descriptor + custom __init__ generation:

class ModelMetaclass(ABCMeta):
    def __new__(mcs, cls_name, bases, namespace, **kwargs):
        cls = super().__new__(mcs, cls_name, bases, namespace, **kwargs)
        cls.__pydantic_init__()
        return cls

The metaclass calls a __pydantic_init__() method that processes annotations, builds the validation schema (via the Rust core), generates the JSON schema, etc.

In modern Python, libraries combine metaclasses + __init_subclass__ + descriptors + decorators. There’s no single “right” tool; each handles one piece.

Metaclass conflicts in multiple inheritance

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

Python can’t decide which metaclass C should use. Fix: a common metaclass that inherits from both:

class MetaAB(MetaA, MetaB): pass

class C(A, B, metaclass=MetaAB): pass     # works

This is the gotcha when combining libraries that each use a metaclass (e.g., Django + ABCMeta).

Performance

Metaclass overhead is at class creation, not instance creation. Each class statement runs the metaclass’s __new__ once. No cost per instance.

For libraries that generate many small classes dynamically, this can add up — pydantic optimizes class creation aggressively. For your own code where classes are defined once, the cost is invisible.

When to NOT use a metaclass

The classic Tim Peters quote: “If you wonder whether you need metaclasses, you don’t. (The people who actually need them know they need them, and don’t need an explanation about why.)”

Translate: metaclasses are powerful but rarely necessary. If a simpler tool works (__init_subclass__, decorator, base class with mixin), use that. Code that uses metaclasses is harder to read, harder to debug, harder for IDEs to introspect.

Decision matrix

Need Tool
Generate __init__, __repr__, __eq__ from fields @dataclass, @attrs.define
Validate fields at runtime + JSON schema Pydantic
Auto-register subclasses __init_subclass__
Validate subclass has required attrs __init_subclass__
Make class instances singletons __call__ on metaclass, or factory function
Build a DSL (Django models, SQLAlchemy ORM) metaclass (or combine with descriptors)
Add methods to every subclass __init_subclass__
Modify class namespace at creation metaclass

__init_subclass__ covers ~80%. Decorators cover another 10%. Metaclasses are for the last 10% — frameworks doing real magic.

abc.ABCMeta

The stdlib metaclass for abstract base classes:

from abc import ABC, abstractmethod

class Repository(ABC):
    @abstractmethod
    def save(self, obj): ...

Instantiating a class that hasn’t implemented all @abstractmethods raises TypeError at construction time. The mechanism is a metaclass (ABCMeta) that hooks into __call__.

In modern code, Protocol (structural typing) often replaces ABC — no inheritance required, just attribute / method matching.

Interview angle

  • “What is a metaclass?” — a class whose instances are other classes. type is the default metaclass. Subclass type and use metaclass=YourMeta to customize class creation. Allows hooking into the construction of classes themselves, not just their instances.
  • “When would you actually use a metaclass?” — almost never in application code. Frameworks (Django ORM, SQLAlchemy, pydantic) use them to make declarative APIs feel native. For 90% of “I want to do something when a subclass is created” cases, use __init_subclass__ instead.
  • “What’s __init_subclass__ and how does it differ from a metaclass?” — a hook called when a subclass is created. Lets you register, validate, inject — without writing a metaclass. Cleaner, simpler, IDE-friendlier. Use it first.
  • “Django uses a metaclass for models.Model. Why not just __init_subclass__?” — partly historical; Django predates __init_subclass__. Also: the metaclass customizes __call__ (creating instances from declared fields) and processes the class namespace at definition. __init_subclass__ happens after the namespace is built.
  • “What’s a metaclass conflict in multiple inheritance?” — Python can’t decide which metaclass a subclass should use when its bases have different metaclasses. Fix: define a common metaclass inheriting from both, use it explicitly. Common when combining Django models with ABCMeta.
  • “Performance cost of metaclasses?” — at class creation time only, not per-instance. For application code where classes are defined once at import, the cost is invisible. Frameworks generating many classes dynamically (per-request, per-tenant) may notice.