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:
- Loads the metaclass (default:
type). - Calls
__prepare__to get the namespace. - Executes the class body code object with this namespace as locals.
- Calls the metaclass with
(name, bases, namespace). - 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)::
Meta.__prepare__("Child", (Parent,))→ returns namespace- Class body executes with namespace as locals
Meta.__new__(Meta, "Child", (Parent,), namespace)→ creates class object__set_name__called on all descriptors in the namespaceParent.__init_subclass__(cls=Child)calledMeta.__init__(Child, "Child", (Parent,), namespace)called- Class decorators applied (outermost last)
- 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.
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.