Skip to content

Module 9: Metaclasses — When Everything Else Is Not Enough

Table of Contents

  1. Introduction
  2. Visual: Tooling Power Ladder
  3. Visual: Class Creation Pipeline
  4. Core 41: type(name, bases, namespace) — Manual Class Creation
  5. Core 42: class X(metaclass=MyMeta) — Resolution, Timing, Conflicts
  6. Core 43: Metaclass __new__ vs __init__
  7. Core 44: __prepare__ — Declaration-Time Enforcement
  8. Capstone: PluginMeta — Automatic Plugin Registration
  9. Glossary (Module 9)

Back to top


Introduction

Metaclasses are Python’s lowest-level, highest-impact hook for customizing class creation. They sit beneath:

  • Descriptors / @property (Modules 7–8): per-attribute semantics.
  • Class decorators (Module 6): post-creation, opt-in class transformation.

Metaclasses intervene before a class exists, during the class statement itself, and they apply automatically across the inheritance tree.

Rule of thumb (non-negotiable):

Use a metaclass only when you must enforce a class-creation invariant across a whole hierarchy, at definition time, and descriptors + class decorators are insufficient.

All python fences are runnable. Any intentional failure is wrapped.

Back to top


Visual: Tooling Power Ladder

Tooling Power Ladder (higher = more magic, higher risk)

┌──────────────────────────────────────────────────────────┐
│ 4  Metaclasses (this module)                             │ ← full class creation control
│ 3  Class decorators (Module 6)                           │ ← post-creation transformation
│ 2  Descriptors / @property (Modules 7–8)                 │ ← per-attribute access control
│ 1  Plain code                                            │ ← explicit, predictable (prefer)
└──────────────────────────────────────────────────────────┘

Caption: Always select the lowest viable level on the ladder.

Back to top


Visual: Class Creation Pipeline

Class Creation Pipeline (conceptual)

Source code:
    class C(Base1, Base2, metaclass=M):
        x = 1
        def f(self): ...

Pipeline steps (executed at import/definition time):
  1. Resolve metaclass M (explicit or derived from bases)
  2. ns = M.__prepare__("C", (Base1, Base2), **kw)          ← optional custom mapping
  3. Execute class body into ns                            ← assignments, defs, etc.
  4. cls = M("C", (Base1, Base2), ns, **kw)
        ├─ M.__new__(...)   ← create class object (mutable ns)
        └─ M.__init__(...)  ← post-creation init/registration
  5. Bind resulting class object to name C in scope

Caption: Entire pipeline runs before any instance of C can exist.

Back to top


Core 41: type(name, bases, namespace) — Manual Class Creation

Canonical idea

type is the default metaclass. In its 3-argument form it constructs a class dynamically.

type(name: str, bases: tuple[type, ...], namespace: dict[str, object]) -> type

Meaning

  • name becomes __name__ (and a simple __qualname__).
  • bases drives MRO and inheritance.
  • namespace is copied into the class dict (exposed as a read-only mappingproxy).

Example: build classes from data

def make_greeter_class(class_name: str, greeting: str):
    def greet(self):
        return f"{greeting} from {self.__class__.__name__}"

    ns = {
        "__doc__": f"{class_name} generated by type()",
        "greet": greet,
        "tag": "generated",
    }
    return type(class_name, (object,), ns)

Hello = make_greeter_class("Hello", "Hi")
h = Hello()
print(h.greet())      # Hi from Hello
print(Hello.tag)      # generated
print(Hello.__doc__)  # Hello generated by type()

Precision note: __module__

A class statement injects __module__ automatically. If you call type(...) directly and care about repr/pickling/docs, set it yourself.

def make_class_in_module(name: str, module: str):
    ns = {"__module__": module, "x": 1}
    return type(name, (object,), ns)

C = make_class_in_module("C", "my_project.models")
print(C.__module__)  # my_project.models

Exercise

Implement dynamic_dataclass(fields: dict[str, type]) -> type using only type(...) that generates:

  • __init__ with a sane signature (inspect.Signature)
  • __repr__

Back to top


Core 42: class X(metaclass=MyMeta) — Resolution, Timing, Conflicts

Timing (no illusions)

Everything in the metaclass pipeline runs at definition time (typically import time). If your metaclass does I/O or heavy work, you are paying it during import.

Visual: Metaclass Resolution

Metaclass Resolution (high-level)

Given: class C(Base1, Base2, ..., metaclass=Explicit?)

Resolution rules:
  ┌─ Explicit metaclass provided? ──► Use it (compatibility check)
  └─ No explicit → derive from bases
        ├─ All bases share a common “most-derived” metaclass?
        │     → Use that
        └─ No compatible common metaclass?
              → TypeError: metaclass conflict

Caption: Conflicts are the most common metaclass surprise in multiple inheritance.

Example: a metaclass that injects a tag

class SimpleMeta(type):
    def __new__(mcs, name, bases, ns):
        ns["tag"] = f"created by {mcs.__name__}"
        return super().__new__(mcs, name, bases, ns)

class Base(metaclass=SimpleMeta):
    pass

class Child(Base):
    pass  # inherits metaclass implicitly

print(Base.tag)   # created by SimpleMeta
print(Child.tag)  # created by SimpleMeta

Example: metaclass conflict (expected failure)

class MetaA(type):
    pass

class MetaB(type):
    pass

class A(metaclass=MetaA):
    pass

class B(metaclass=MetaB):
    pass

try:
    class Bad(A, B):
        pass
except TypeError as e:
    print("Expected:", e)

Fix pattern: a joint metaclass (only if semantically valid)

class MetaAB(MetaA, MetaB):
    pass

class A2(metaclass=MetaAB):
    pass

class B2(metaclass=MetaAB):
    pass

class OK(A2, B2):
    pass

print(type(OK) is MetaAB)  # True

Back to top


Core 43: Metaclass __new__ vs __init__

Visual: the split

Metaclass __new__ vs __init__ Split

Metaclass.__new__(mcs, name, bases, ns, **kw)
  ┌──────────────────────────────────────────────┐
  │ Receives mutable namespace (ns)              │
  │ Ideal for:                                   │
  │   • structural edits (inject/rename attrs)   │
  │   • rewriting bases                          │
  │   • validation needing full namespace        │
  └──────────────────────────────────────────────┘
              Creates and returns class object

Metaclass.__init__(cls, name, bases, ns, **kw)
  ┌──────────────────────────────────────────────┐
  │ Receives created class (cls)                 │
  │ cls.__dict__ is now read-only mappingproxy    │
  │ Ideal for:                                   │
  │   • registration                             │
  │   • post-creation validation (final MRO)     │
  │   • bookkeeping (use setattr(cls, ...))      │
  └──────────────────────────────────────────────┘

Caption: Structural changes in __new__; bookkeeping/registration in __init__.

Example: enforce an interface + register classes

class ValidatingMeta(type):
    registry = []

    def __new__(mcs, name, bases, ns):
        has_run_here = "run" in ns
        has_run_in_bases = any(hasattr(b, "run") for b in bases)
        if not has_run_here and not has_run_in_bases:
            raise TypeError(f"{name} must define run()")

        ns["from_meta"] = lambda self: "ok"
        return super().__new__(mcs, name, bases, ns)

    def __init__(cls, name, bases, ns):
        super().__init__(name, bases, ns)
        ValidatingMeta.registry.append(cls)

class Task(metaclass=ValidatingMeta):
    def run(self):
        return "running"

print(Task().from_meta())              # ok
print(Task in ValidatingMeta.registry) # True

try:
    class BadTask(metaclass=ValidatingMeta):
        pass
except TypeError as e:
    print("Expected:", e)

Exercise

Write AutoReprMeta that injects __repr__ in __new__ using __annotations__:

  • include only public annotated fields
  • handle missing attributes: getattr(self, name, "<missing>")

Back to top


Core 44: __prepare__ — Declaration-Time Enforcement

Visual: why __prepare__ is unique

__prepare__ Timing (why it is unique)

Without __prepare__:
  ┌──────────────────────────────┐
  │ class body executes into      │
  │ ordinary dict (order preserved)│
  │ → metaclass sees final ns only│
  └──────────────────────────────┘

With __prepare__:
  ┌──────────────────────────────┐
  │ Custom mapping supplied       │
  │ before any body statement     │
  │ → can intercept/validate      │
  │   each assignment as it happens│
  └──────────────────────────────┘

Caption: __prepare__ enables declaration-time enforcement impossible otherwise.

Example: forbid duplicate assignment (except dunders)

class NoDupesMeta(type):
    class NoDupesDict(dict):
        def __setitem__(self, key, value):
            is_dunder = key.startswith("__") and key.endswith("__")
            if key in self and not is_dunder:
                raise ValueError(f"Duplicate assignment to {key!r}")
            super().__setitem__(key, value)

    @classmethod
    def __prepare__(mcs, name, bases, **kw):
        return NoDupesMeta.NoDupesDict()

class OK(metaclass=NoDupesMeta):
    x = 1
    y = 2

print(OK.x, OK.y)  # 1 2

try:
    class Bad(metaclass=NoDupesMeta):
        x = 1
        x = 2
except ValueError as e:
    print("Expected:", e)

Exercise

Write TracingMeta:

  • __prepare__ returns a mapping that records assignment order
  • __new__ stores that order as __definition_order__

Back to top


Capstone: PluginMeta — Automatic Plugin Registration

This demonstrates the main “honest” use case for metaclasses: automatic, hierarchy-wide enforcement and registration.

Design goals:

  • Base classes opt out via __abstract__ = True
  • Subclasses register by group
  • Duplicate names rejected within a group
  • Registry is testable via clear()

Visual: metaclass-driven registry

Metaclass-Driven Registry (import-time behavior)

Module import
class Plugin(... , metaclass=PluginMeta):
   ├─ class body executes into namespace
   └─ PluginMeta.__new__ runs
          ├─ validate invariants (e.g., no duplicates)
          └─ register class into _registry[group]

Subsequent imports → additional registrations
All concrete subclasses register automatically

Caption: Registration is automatic and hierarchy-wide (infectious).

Implementation (runnable, testable)

from collections import defaultdict
from threading import RLock
from typing import Dict, List, Tuple, Type, Optional

_registry: Dict[str, List[Tuple[str, Type[object]]]] = defaultdict(list)
_lock = RLock()

class PluginMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases, **kw):
        return dict()

    def __new__(mcs, name, bases, ns, **kw):
        cls = super().__new__(mcs, name, bases, ns)

        if ns.get("__abstract__", False):
            return cls

        group = ns.get("group")
        if group is None:
            for b in bases:
                if hasattr(b, "group"):
                    group = getattr(b, "group")
                    break
        if group is None:
            group = "default"
        cls.group = group

        with _lock:
            items = _registry[group]
            if any(existing_name == name for existing_name, _ in items):
                raise ValueError(f"Duplicate plugin {name!r} in group {group!r}")
            items.append((name, cls))
            items.sort(key=lambda t: t[0])

        return cls

    @classmethod
    def get_plugins(mcs, group: str) -> List[Tuple[str, Type[object]]]:
        with _lock:
            return list(_registry.get(group, []))

    @classmethod
    def clear(mcs, group: Optional[str] = None) -> None:
        with _lock:
            if group is None:
                _registry.clear()
            else:
                _registry.pop(group, None)

# Usage

class Logger(metaclass=PluginMeta):
    __abstract__ = True
    group = "logging"
    def log(self, msg: str) -> str:
        raise NotImplementedError

class FileLogger(Logger):
    def log(self, msg: str) -> str:
        return f"[FILE] {msg}"

class ConsoleLogger(Logger):
    def log(self, msg: str) -> str:
        return f"[CONSOLE] {msg}"

print([name for name, _ in PluginMeta.get_plugins("logging")])
# ['ConsoleLogger', 'FileLogger']

Caveats (must be stated bluntly)

  • Import-order dependent registration.
  • Reload can double-register unless you clear/reset.
  • Metaclass conflicts with other frameworks’ metaclasses are common.
  • Global registries complicate tests unless you expose reset hooks (we did).

Exercise

Extend the capstone:

  1. Add priority: int = 0 and sort by (-priority, name).
  2. Add disable(name: str, group: str) to remove an entry.
  3. Add tests that call PluginMeta.clear() between runs.

Back to top


Glossary (Module 9)

Term Definition
Metaclass The “class of a class”; customizes how classes are created and initialized.
type(name, bases, ns) The primitive 3-arg constructor that builds a class dynamically (what a class statement ultimately uses).
Class creation pipeline The definition-time sequence: resolve metaclass → __prepare__ → execute body → metaclass __new__ → metaclass __init__.
Definition time / import time When class bodies execute and metaclass hooks run; heavy work here slows imports and can create side effects.
Metaclass resolution The rule set that chooses the effective metaclass from an explicit metaclass= or from base classes.
Metaclass conflict TypeError raised when bases’ metaclasses have no compatible common “most-derived” metaclass in multiple inheritance.
Joint metaclass A metaclass that subclasses multiple metaclasses to satisfy conflict rules (only valid if the behaviors compose).
__prepare__ Metaclass hook returning the namespace mapping used while executing the class body (enables declaration-time enforcement).
Custom namespace mapping A dict-like object returned by __prepare__ that can intercept assignments (e.g., forbid duplicates, record order).
Declaration-time enforcement Constraints enforced during class body execution (via custom namespace), impossible to implement reliably after the fact.
Metaclass __new__ Constructs the class object from (name, bases, ns); best place for structural edits and namespace-driven validation.
Metaclass __init__ Runs after class creation; best place for registration and bookkeeping using setattr (final MRO is known).
Namespace mutability window The phase where ns is still mutable (inside metaclass __new__); after creation, cls.__dict__ is a read-only mappingproxy.
mappingproxy Read-only view of a class’s dict (cls.__dict__), preventing direct mutation post-creation.
Class decorators Post-creation, opt-in class transformations (less invasive than metaclasses; don’t affect subclasses automatically).
Hierarchy-wide invariants Rules enforced automatically for every subclass in an inheritance tree (the main legitimate reason to use a metaclass).
Automatic registration Pattern where concrete subclasses register themselves at definition time into a registry (e.g., plugins).
Abstract base opt-out Convention like __abstract__ = True to prevent base classes from being registered/enforced as concrete plugins.
Registry A global or metaclass-owned mapping from keys (e.g., group) to classes; must be resettable for tests.
Duplicate-name rejection Enforcing uniqueness constraints at class creation (e.g., no two plugins with the same name in one group).
Import-order dependence Registration side effects depend on what modules import first; can cause missing/extra registrations across runs.
Reload hazard Module reload can re-run class definitions and double-register unless the registry is cleared or guarded.
Test isolation hook A clear()/reset API to make global registries deterministic in unit tests.
Infectious behavior Metaclass effects propagate to subclasses automatically (useful for invariants, risky for surprises).
MRO (method resolution order) The linearization that determines attribute lookup across bases; finalized after class creation (relevant to __init__).
__module__ injection class statements set __module__ automatically; manual type(...) creation should set it when repr/pickling/docs matter.
Signature injection Attaching __signature__ (or generating __init__) during class creation to control introspection and call semantics.
Metaclass vs descriptor Metaclasses coordinate class-level structure and invariants; descriptors implement per-attribute behavior once the class exists.
Power ladder Heuristic: prefer plain code → descriptors → class decorators → metaclasses; metaclasses are the highest-magic, highest-risk tool.

Proceed to Module 10.

Back to top