A class statement looks declarative, but Python executes it. It evaluates bases and keywords, chooses a metaclass, asks for a namespace, runs the body against that namespace, and finally asks the metaclass to create the class object. Only then do descriptor naming and subclass initialization hooks complete the process.

This pipeline powers data classes, abstract base classes, ORMs, enums, registration systems, and simple project-level validation. Understanding its phases prevents the common mistake of reaching for a metaclass when __init_subclass__, __set_name__, or a class decorator is enough.

Python guarantee. Class creation follows the steps documented in the data model: resolve non-type bases, determine a metaclass, prepare a namespace, execute the body, create the class, then run creation hooks. Ordinary classes are instances of type.

Version note. Experiments were verified on CPython 3.14.7 and use behavior available in Python 3.10+. Compiler opcodes, frame layout, dictionary internals, and C entry points are CPython 3.14 details. Hook ordering and the documented __build_class__ protocol are language behavior.

Experiment 1: a class body is executable code

Pyodide / WebAssembly
events = []


def field(name):
    events.append(f"field:{name}")
    return name.upper()


class Message:
    events.append("body:start")
    subject = field("subject")
    for number in range(2):
        events.append(f"loop:{number}")
    events.append("body:end")


print("[event] class-body execution:", events)
print("[result] Message subject:", Message.subject)
print("[check] loop target retained on class:", "number" in Message.__dict__)

The body runs immediately, once, when execution reaches the statement. Its assignments populate a class namespace. Even the loop target becomes a class attribute. A class block supports control flow and function calls, but using arbitrary side effects there makes import behavior harder to reason about.

Names read in a class body follow special execution rules: local class names are found in the prepared namespace, while enclosing function variables and globals remain available according to normal resolution. Methods do not close over the class namespace. Referring to a sibling class attribute inside a method therefore requires self.name, type(self).name, or a class name, not a bare local.

Experiment 2: methods do not capture class locals

Pyodide / WebAssembly
prefix = "global"


class Label:
    prefix = "class"

    def bare(self):
        return prefix

    def qualified(self):
        return self.prefix


print("[result] bare prefix lookup:", Label().bare())
print("[result] qualified prefix lookup:", Label().qualified())

The results are global and class. The function body is compiled while the class body runs, but the class namespace is not an enclosing lexical function scope. This is a language rule, not evidence that class attributes were copied late.

Selecting the metaclass

Python first evaluates the bases. Entries that are not type instances may provide __mro_entries__, which can replace themselves with actual bases. Python then computes the most-derived metaclass compatible with all explicit and inherited candidates. If no candidate is a subclass of every other candidate, class creation fails with a metaclass conflict.

Most code should not customize this stage. Metaclasses affect every subclass and can conflict when libraries meet. They are appropriate when construction of the class object itself must be controlled across an open hierarchy.

Experiment 3: observe prepare, body, and construction

Pyodide / WebAssembly
events = []


class Meta(type):
    @classmethod
    def __prepare__(mcls, name, bases, **kwargs):
        events.append(f"prepare:{name}")
        return {}

    def __new__(mcls, name, bases, namespace, **kwargs):
        events.append(f"new:{name}:{list(namespace)}")
        return super().__new__(mcls, name, bases, namespace)

    def __init__(cls, name, bases, namespace, **kwargs):
        events.append(f"init:{name}")
        super().__init__(name, bases, namespace)


class Product(metaclass=Meta):
    events.append("body:Product")
    sku = "A-1"


print("[event] class construction order:", events)

__prepare__ runs before the body. Meta.__new__ receives the populated mapping and returns the class. Meta.__init__ then initializes that class object. The namespace also contains compiler-supplied names such as __module__ and __qualname__; exact contents can vary with body features and version.

Since ordinary dictionaries preserve insertion order as a Python guarantee, a custom ordered mapping is no longer needed merely to remember declaration order. __prepare__ remains useful for specialized validation or recording, but surprising mapping behavior can break compiler expectations. Return a real mapping and preserve required entries.

Experiment 4: descriptors learn their assigned names

Pyodide / WebAssembly
class Field:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.storage_name = f"_{name}"

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        setattr(instance, self.storage_name, value)


class Account:
    balance = Field()

    def __init__(self, balance):
        self.balance = balance


account = Account(25)
print("[state] descriptor public name:", Account.balance.public_name)
print("[result] balance and instance state:", account.balance, account.__dict__)

type.__new__ calls __set_name__(owner, name) on attributes that define it. The descriptor no longer needs the field name repeated in its constructor. If a descriptor is attached after class creation, Python does not automatically replay the hook; call it explicitly or design a supported installation API.

CPython 3.14 detail. type_new_set_names performs these calls in CPython's typeobject.c. Depending on that function name is not portable; depending on documented __set_name__ behavior is.

__init_subclass__: the common lightweight hook

Whenever a class is subclassed, Python calls the immediate parent's __init_subclass__. The default on object accepts no keywords. A cooperative hook should consume its own class keywords and delegate the remainder, just like a cooperative initializer.

This hook can validate declarations, fill derived attributes, or register subclasses. It does not require changing the metaclass and composes through normal MRO rules.

Experiment 5: validate class declarations

Pyodide / WebAssembly
class Plugin:
    registry = {}

    def __init_subclass__(cls, *, key, **kwargs):
        super().__init_subclass__(**kwargs)
        if key in cls.registry:
            raise ValueError(f"duplicate plugin: {key}")
        cls.registry[key] = cls
        cls.key = key


class JSONPlugin(Plugin, key="json"):
    pass


print("[result] plugin key:", JSONPlugin.key)
print("[state] registered plugin class:", Plugin.registry["json"].__name__)

The key keyword is not passed to type.__new__ as an application attribute. Class-creation keywords flow to the relevant hooks. Registration here creates a strong reference from the registry to each class; for dynamic plugin unloading, that lifetime decision deserves explicit design.

Class decorators run after the class exists

A decorator receives the completed class and returns the object bound to the class name. It is local and visually explicit, making it a good fit for transformations or registrations that do not need to control descendants' construction.

Decorators are applied from the bottom upward, like function decorators. They can return another class or any object, although replacing a class with an unrelated value is usually hostile to readers and typing tools.

Experiment 6: compare decorator timing

Pyodide / WebAssembly
events = []


class Base:
    def __init_subclass__(cls, **kwargs):
        events.append(f"subclass:{cls.__name__}")
        super().__init_subclass__(**kwargs)


def registered(cls):
    events.append(f"decorator:{cls.__name__}")
    cls.registered = True
    return cls


@registered
class Handler(Base):
    events.append("body")


print("[event] class hook and decorator order:", events)
print("[check] handler registered:", Handler.registered)

The sequence is body, subclass hook, decorator. Code in __init_subclass__ cannot observe mutations that a decorator has not yet made. Conversely, the decorator receives a class whose descriptors and subclass hooks have already run.

Dynamic construction with type

The three-argument call type(name, bases, namespace) constructs a class using normal metaclass machinery. It is useful when names and methods are genuinely data-driven. A class statement is preferable when the shape is static because tools, tracebacks, and readers see the declaration directly.

Experiment 7: build equivalent classes two ways

Pyodide / WebAssembly
def describe(self):
    return f"item:{self.code}"


Dynamic = type("Dynamic", (), {"code": 7, "describe": describe})


class Static:
    code = 7
    describe = describe


print("[result] dynamic class description:", Dynamic().describe())
print("[result] static class description:", Static().describe())
print("[check] Dynamic is an instance of type:", type(Dynamic) is type)

Both methods bind through the descriptor protocol because function objects in either namespace are descriptors. Dynamic construction is not a lesser category of class. It simply bypasses the readable class-body syntax.

For dynamic bases and class keywords, types.new_class exposes the full preparation pipeline more conveniently than directly coordinating metaclasses. Use the standard helper rather than imitating __build_class__ internals.

__classcell__ is compiler-metaclass cooperation

Methods using zero-argument super() or __class__ require a closure cell populated with the newly created class. The compiler places __classcell__ in the class namespace. A metaclass that copies or filters the namespace must pass that entry through to type.__new__.

Experiment 8: zero-argument super survives a careful metaclass

Pyodide / WebAssembly
class Meta(type):
    def __new__(mcls, name, bases, namespace):
        copied = dict(namespace)
        return super().__new__(mcls, name, bases, copied)


class Base:
    def value(self):
        return 10


class Child(Base, metaclass=Meta):
    def value(self):
        return super().value() + 1


print("[result] cooperative child value:", Child().value())
print("[check] class cell remains in class dictionary:", "__classcell__" in Child.__dict__)

It prints 11; the cell has done its construction job and need not remain as an ordinary class attribute. Removing __classcell__ before delegating produces an error for this class. The exact message is version-specific, while the metaclass obligation to propagate the cell is documented behavior.

Choosing the smallest mechanism

Use a plain class statement for static declarations. Use __set_name__ when a descriptor needs its owner and attribute name. Use __init_subclass__ for cooperative validation or setup of future subclasses. Use a class decorator for an explicit one-class transformation. Use a metaclass only when namespace preparation or the creation and behavior of class objects must change across a hierarchy.

Keep import-time work cheap and deterministic. A class body and all these hooks commonly run during import. Network calls, filesystem discovery, and environment-dependent registration make tests and startup fragile. Store declarations during construction; perform external work in an explicit application phase.

When metaclasses are necessary, delegate to super, preserve __classcell__, accept compatible keywords, and test interaction with other framework bases. A metaclass conflict is often a sign that two frameworks both claim control over class construction. An adapter or composition boundary may be safer than inventing a combined metaclass.

Failure timing is part of the interface

Class hooks move errors to import or declaration time. That can be excellent: an invalid field declaration fails before the application handles traffic. It can also make optional modules impossible to import, test discovery fail globally, or plugin errors crash unrelated commands. Decide deliberately whether a rule is structural enough to enforce while building the class or environmental enough to validate during application startup.

Error messages should identify the new class and offending declaration. Raise TypeError for malformed class API usage and a domain-specific exception only when callers can reasonably recover. Avoid catching broad exceptions around class statements; a failure may come from any body expression, descriptor hook, base hook, decorator, or metaclass operation.

Ordering also affects registration. A base's __init_subclass__ sees the newly created class before decorators run. If registration requires decorator-produced metadata, register in that decorator or split declaration from a later explicit registry build. If a descriptor must know metadata from sibling descriptors, wait until all __set_name__ calls are complete, typically in __init_subclass__, rather than assuming dictionary iteration side effects.

Tools reflect different views. Class.__dict__ is a read-only mapping proxy over the class namespace, vars(Class) exposes that same mapping view, and inherited attributes are absent. dir(Class) synthesizes a broader list and may be customized. For framework introspection, use the documented registry or field API instead of reconstructing creation history from dir.

Finally, remember that assigning an attribute after creation is ordinary class mutation. It does not replay decorators, __init_subclass__, or automatically call __set_name__. A framework supporting dynamic fields needs one explicit installation operation that performs all required bookkeeping and invalidates any caches. Treating setattr as equivalent to a source-level declaration is a frequent source of half-configured descriptors.

Exercises

  1. Write a descriptor that records its assigned name and validates positive integers. Attach another instance after construction and observe the missing hook.
  2. Add two cooperative __init_subclass__ hooks that consume separate keywords. Reverse base order and inspect the MRO.
  3. Build a logging namespace in __prepare__ that rejects duplicate assignments. Decide whether rejecting method redefinition is worth the surprise.
  4. Convert a registration metaclass into __init_subclass__, then list the capability you gave up.
  5. Predict the order of two class decorators, a descriptor's __set_name__, and a base's __init_subclass__; verify it with an event list.

Keep this model

A class statement is a managed execution pipeline, not a record literal. Namespace preparation precedes body execution. The metaclass turns the populated namespace into a class. type.__new__ coordinates descriptor naming and class-cell setup, subclass hooks initialize the new relationship, and decorators transform the completed result.

That ordering tells you where an extension belongs. Most application designs need one focused hook, not a metaclass. Choosing the latest, narrowest phase that can solve the problem reduces conflicts and makes import-time behavior visible.

Primary sources