Class Body Execution — Deep Dive

The Bytecode View

When CPython compiles a module containing a class, the class body becomes a separate code object. The class statement compiles to bytecode that:

  1. Loads the metaclass (default: type).
  2. Calls __prepare__ to get the namespace.
  3. Executes the class body code object with this namespace as locals.
  4. Calls the metaclass with (name, bases, namespace).
  5. Stores the result in the local variable matching the class name.

You can observe this with dis:

import dis

code = """
class Foo(Base):
    x = 1
"""

dis.dis(compile(code, "<string>", "exec"))

The disassembly shows LOAD_BUILD_CLASS, followed by loading the class body function and the base classes, then a CALL instruction that triggers the metaclass machinery.

The LOAD_BUILD_CLASS Opcode

This opcode pushes the __build_class__ builtin onto the stack. __build_class__ is the function that orchestrates the entire class creation process:

# Simplified pseudocode of __build_class__
def __build_class__(func, name, *bases, metaclass=None, **kwargs):
    if metaclass is None:
        metaclass = type  # Default metaclass

    # Step 1: Prepare namespace
    if hasattr(metaclass, '__prepare__'):
        ns = metaclass.__prepare__(name, bases, **kwargs)
    else:
        ns = {}

    # Step 2: Execute class body
    func(ns)  # The class body is compiled as a function

    # Step 3: Create the class
    cls = metaclass(name, bases, ns, **kwargs)
    return cls

The __prepare__ Hook

__prepare__ returns the namespace mapping used during class body execution. This is a powerful hook for metaprogramming.

Tracking Attribute Definition Order

class OrderTracker(dict):
    """A dict that records the order items are defined."""
    def __init__(self):
        super().__init__()
        self._order = []

    def __setitem__(self, key, value):
        if key not in self and not key.startswith('_'):
            self._order.append(key)
        super().__setitem__(key, value)


class OrderedMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases):
        return OrderTracker()

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, dict(namespace))
        cls._field_order = namespace._order
        return cls


class Form(metaclass=OrderedMeta):
    name = "text"
    email = "email"
    age = "number"

print(Form._field_order)  # ['name', 'email', 'age']

Preventing Redefinition

class StrictNamespace(dict):
    def __setitem__(self, key, value):
        if key in self and not key.startswith('_'):
            raise TypeError(f"Cannot redefine '{key}'")
        super().__setitem__(key, value)


class StrictMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases):
        return StrictNamespace()

Class Body Scoping Rules

The class body creates a unique scope that follows different rules than function scopes:

No Implicit Access to Class Variables from Methods

class Broken:
    x = 10
    def method(self):
        return x  # NameError! x is not in method's scope

class Fixed:
    x = 10
    def method(self):
        return self.x  # Works — goes through attribute lookup

Class body variables are not in the enclosing scope of methods defined in the class. This is a deliberate design choice — methods should access class attributes through self or cls, not through closure.

Comprehensions and Class Scope

In Python 3, comprehensions have their own scope. This interacts surprisingly with class bodies:

class Surprise:
    items = [1, 2, 3]
    # This works — items is evaluated in the class scope
    doubled = [x * 2 for x in items]

    multiplier = 10
    # This FAILS in Python 3:
    # scaled = [x * multiplier for x in items]
    # NameError: name 'multiplier' is not defined

The comprehension’s iterable (items in for x in items) is evaluated in the class scope. But the expression (x * multiplier) is evaluated in the comprehension’s own scope, where class variables aren’t visible.

Fix: Use a helper function or pass the value explicitly.

class Fixed:
    items = [1, 2, 3]
    multiplier = 10
    scaled = (lambda m=multiplier: [x * m for x in items])()

The __set_name__ Protocol

After type.__new__ creates the class, it calls __set_name__ on every object in the class namespace that has it:

class Descriptor:
    def __set_name__(self, owner, name):
        self.owner = owner
        self.name = name
        self.storage_name = f"_{name}"
        print(f"Descriptor '{name}' attached to {owner.__name__}")

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

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


class Model:
    title = Descriptor()   # __set_name__ called: "Descriptor 'title' attached to Model"
    author = Descriptor()  # __set_name__ called: "Descriptor 'author' attached to Model"

__set_name__ is called in the order attributes appear in the class namespace. It fires after the class object exists but before __init_subclass__ on parent classes.

The __init_subclass__ Hook

After __set_name__ runs for all descriptors, Python calls __init_subclass__ on the parent class:

class Plugin:
    _plugins: dict[str, type] = {}

    def __init_subclass__(cls, /, plugin_name: str = "", **kwargs):
        super().__init_subclass__(**kwargs)
        if plugin_name:
            Plugin._plugins[plugin_name] = cls
        print(f"Subclass {cls.__name__} created (plugin_name={plugin_name!r})")


class AuthPlugin(Plugin, plugin_name="auth"):
    pass
# Prints: Subclass AuthPlugin created (plugin_name='auth')

The Full Sequence

Here’s the complete order of operations for class Child(Parent, metaclass=Meta)::

  1. Meta.__prepare__("Child", (Parent,)) → returns namespace
  2. Class body executes with namespace as locals
  3. Meta.__new__(Meta, "Child", (Parent,), namespace) → creates class object
  4. __set_name__ called on all descriptors in the namespace
  5. Parent.__init_subclass__(cls=Child) called
  6. Meta.__init__(Child, "Child", (Parent,), namespace) called
  7. Class decorators applied (outermost last)
  8. Result assigned to the variable Child

PEP 649 and Lazy Annotations (Python 3.14)

Python 3.14 introduces lazy evaluation of annotations (PEP 649). This changes class body execution because annotations are no longer evaluated at class definition time:

# Python 3.13 and earlier:
class Foo:
    x: SomeType = 1  # SomeType must exist at class definition time

# Python 3.14+:
class Foo:
    x: SomeType = 1  # SomeType can be a forward reference

This affects metaclasses and tools that inspect __annotations__ at class creation time — they must now use typing.get_type_hints() or call the annotations descriptor explicitly.

Performance: Class Creation Overhead

Class creation is relatively expensive compared to function definition:

  • Function definition: ~200ns (just creates a function object)
  • Class creation (simple): ~2-5μs (namespace prep, body exec, type call)
  • Class creation (with metaclass): ~5-20μs depending on metaclass complexity

This rarely matters because classes are typically defined once at import time. But if you’re generating classes dynamically in a loop (e.g., ORM model generation), the overhead can add up. In such cases, consider using type() directly:

# Creating a class dynamically without the class statement
attrs = {'x': 1, 'method': lambda self: self.x}
MyClass = type('MyClass', (object,), attrs)

Advanced Pattern: Class as Configuration

Leveraging class body execution for declarative configuration:

class FieldCollectorMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases):
        return {}

    def __new__(mcs, name, bases, namespace):
        fields = {
            k: v for k, v in namespace.items()
            if not k.startswith('_') and not callable(v)
        }
        cls = super().__new__(mcs, name, bases, namespace)
        cls._fields = fields
        return cls


class Config(metaclass=FieldCollectorMeta):
    """Configuration defined as a class — fields extracted automatically."""
    database_url = "postgresql://localhost/mydb"
    debug = False
    max_connections = 10
    secret_key = "change-me"

print(Config._fields)
# {'database_url': 'postgresql://localhost/mydb', 'debug': False,
#  'max_connections': 10, 'secret_key': 'change-me'}

This pattern is used by Django settings, Flask-RESTful schemas, and pytest configuration.

One thing to remember: A Python class statement compiles the body into a code object, executes it in a prepared namespace, then calls the metaclass to build the class. Every step is hookable — __prepare__, __new__, __init__, __set_name__, __init_subclass__ — making Python’s class creation one of the most customizable in any mainstream language.

pythonadvancedoopinternals

See Also

  • Python Attribute Lookup Chain How Python finds your variables and methods — like checking your pockets, then your bag, then your locker, in a specific order every time.
  • Python Bytecode And Interpreter How your .py file turns into tiny instructions the Python interpreter can execute step by step.
  • Python Data Model Customization How Python lets your objects behave like built-in types — adding, comparing, looping, and printing, all with special methods.
  • Python Garbage Collection See how Python cleans up unreachable objects, especially the tricky ones that point at each other.
  • Python Gil Why Python threads can feel stuck in traffic, and how the GIL explains the behavior.