CPython's Module Implementation in C
PyModuleObject, md_dict, per-module state, PyModuleDef, slots, spec, getattro, and lazy imports traced through the CPython 3.15 source.
Every Python module is, at the C level, an instance of PyModule_Type. Unlike
bool or int, a module is a mutable object whose “attributes” are just the
entries of its dictionary. This article traces that implementation through the
3.15 source: the struct, __dict__, per-module state, PyModuleDef, the slot
machinery, __spec__, attribute lookup, and the new lazy-import placeholders.
The struct: PyModuleObject
The definition lives in Include/internal/pycore_moduleobject.h (it is
internal, not part of the limited API):
// Include/internal/pycore_moduleobject.h
typedef struct {
PyObject_HEAD
PyObject *md_dict;
void *md_state;
PyObject *md_weaklist;
// for logging purposes after md_dict is cleared
PyObject *md_name;
bool md_token_is_def; /* if true, `md_token` is the PyModuleDef */
#ifdef Py_GIL_DISABLED
bool md_requires_gil;
#endif
Py_ssize_t md_state_size;
traverseproc md_state_traverse;
inquiry md_state_clear;
freefunc md_state_free;
void *md_token;
_Py_modexecfunc md_exec; /* only set if md_token_is_def is true */
} PyModuleObject;The fields fall into three groups:
md_dictis the module’s namespace. It is the object you get frommodule.__dict__.md_state/md_state_size/md_state_*are the per-module C state used by extension modules (them_size/m_traverse/m_clear/m_freeof aPyModuleDef).md_token(plusmd_token_is_def) records where the module came from: for a module built from aPyModuleDefit points at that def; for a module built from slots it points at the token passed viaPy_mod_token(or NULL).
PyModule_Type is declared in Include/moduleobject.h and defined at the
bottom of Objects/moduleobject.c:
// Objects/moduleobject.c
PyTypeObject PyModule_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"module",
sizeof(PyModuleObject), /* tp_basicsize */
0, /* tp_itemsize */
module_dealloc, /* tp_dealloc */
...
_Py_module_getattro, /* tp_getattro */
PyObject_GenericSetAttr, /* tp_setattro */
...
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
Py_TPFLAGS_BASETYPE, /* tp_flags */
...
offsetof(PyModuleObject, md_weaklist), /* tp_weaklistoffset */
...
module_methods, /* tp_methods */
module_members, /* tp_members */
module_getsets, /* tp_getset */
0, /* tp_base */
0, /* tp_dict */
...
offsetof(PyModuleObject, md_dict), /* tp_dictoffset */
module___init__, /* tp_init */
0, /* tp_alloc */
new_module, /* tp_new */
PyObject_GC_Del, /* tp_free */
};tp_dictoffset is offsetof(PyModuleObject, md_dict), so module.__dict__
resolves straight to the field. tp_weaklistoffset enables weakref support.
Py_TPFLAGS_BASETYPE is set, so users can subclass types.ModuleType.
dict: the namespace
md_dict is created eagerly in new_module_notrack:
// Objects/moduleobject.c
static PyModuleObject *
new_module_notrack(PyTypeObject *mt)
{
PyModuleObject *m;
m = (PyModuleObject *)_PyType_AllocNoTrack(mt, 0);
if (m == NULL)
return NULL;
m->md_state = NULL;
m->md_weaklist = NULL;
m->md_name = NULL;
m->md_token_is_def = false;
#ifdef Py_GIL_DISABLED
m->md_requires_gil = true;
#endif
m->md_state_size = 0;
...
m->md_dict = PyDict_New();
if (m->md_dict == NULL) {
Py_DECREF(m);
return NULL;
}
return m;
}The __dict__ is exposed as a read-only member:
// Objects/moduleobject.c
static PyMemberDef module_members[] = {
{"__dict__", _Py_T_OBJECT, offsetof(PyModuleObject, md_dict), Py_READONLY},
{0}
};PyModule_GetDict returns a borrowed reference to it:
// Objects/moduleobject.c
PyObject *
PyModule_GetDict(PyObject *m)
{
if (!PyModule_Check(m)) {
PyErr_BadInternalCall();
return NULL;
}
return _PyModule_GetDict(m); // borrowed reference
}When a module is created with PyModule_NewObject, the dict is seeded by
module_init_dict, which sets the standard dunder keys:
// Objects/moduleobject.c
static int
module_init_dict(PyModuleObject *mod, PyObject *md_dict,
PyObject *name, PyObject *doc)
{
if (doc == NULL)
doc = Py_None;
if (PyDict_SetItem(md_dict, &_Py_ID(__name__), name) != 0)
return -1;
if (PyDict_SetItem(md_dict, &_Py_ID(__doc__), doc) != 0)
return -1;
if (PyDict_SetItem(md_dict, &_Py_ID(__package__), Py_None) != 0)
return -1;
if (PyDict_SetItem(md_dict, &_Py_ID(__loader__), Py_None) != 0)
return -1;
if (PyDict_SetItem(md_dict, &_Py_ID(__spec__), Py_None) != 0)
return -1;
if (PyUnicode_CheckExact(name)) {
Py_XSETREF(mod->md_name, Py_NewRef(name));
}
return 0;
}So every module starts life with __name__, __doc__, __package__,
__loader__ and __spec__ already in its dict.
Attribute lookup: _Py_module_getattro
Modules do not use the default tp_getattro; they use _Py_module_getattro,
which is where lazy imports are hooked in. It first tries the generic attribute
lookup against the type/dict, then falls back to the module dict, and on a miss
tries to load a lazy submodule:
// Objects/moduleobject.c
PyObject*
_Py_module_getattro_impl(PyModuleObject *m, PyObject *name, int suppress)
{
attr = _PyObject_GenericGetAttrWithDict((PyObject *)m, name, NULL, suppress);
if (attr == NULL) {
... // clear AttributeError
assert(m->md_dict != NULL);
int recheck_dict;
attr = _PyImport_TryLoadLazySubmodule((PyObject *)m, name, suppress,
&recheck_dict);
if (attr != NULL || PyErr_Occurred()) {
return attr;
}
// recheck_dict path ...
if (recheck_dict &&
PyDict_GetItemRef(m->md_dict, name, &attr) < 0) {
return NULL;
}
}
if (attr) {
if (PyLazyImport_CheckExact(attr)) {
PyThreadState *tstate = PyThreadState_GET();
...
PyObject *new_value = _PyLazyImport_Reify(
tstate, attr, name, m->md_dict);
Py_DECREF(attr);
return new_value;
}
return attr;
}
// __getattr__ fallback, then a detailed AttributeError ...
}Two things to notice:
- A lazy submodule (
importlib’s pending-child machinery) can be materialized on first access via_PyImport_TryLoadLazySubmodule. - If the stored value is a
PyLazyImportplaceholder, accessing the attribute reifies it:_PyLazyImport_Reifyruns the import and atomically replaces the placeholder in the dict with the real object.
This is the mechanism behind PEP 810’s lazy import statements: the compiler
emits a PyLazyImport object into the module dict, and the first read triggers
resolution.
Per-module state
Extension modules (and modules created from slots) carry C-level state. The
PyModuleDef describes it:
// Include/moduleobject.h
struct PyModuleDef {
PyModuleDef_Base m_base;
const char* m_name;
const char* m_doc;
Py_ssize_t m_size; // state size, or -1 for "no state"
PyMethodDef *m_methods;
PyModuleDef_Slot *m_slots;
traverseproc m_traverse;
inquiry m_clear;
freefunc m_free;
};m_size is the number of bytes to allocate for md_state; a negative value
means “no state / single-phase legacy”. When a module is created from a def via
_PyModule_CreateInitialized, the state is allocated and zeroed:
// Objects/moduleobject.c
if (module->m_size > 0) {
m->md_state = PyMem_Malloc(module->m_size);
if (!m->md_state) {
PyErr_NoMemory();
Py_DECREF(m);
return NULL;
}
memset(m->md_state, 0, module->m_size);
}
m->md_token = module;
m->md_token_is_def = true;
module_copy_members_from_deflike(m, module); // copies size/traverse/clear/free
For the newer multi-phase (slots) path, module_from_slots_and_spec does the
same copying from a def_like (a PyModuleDef or a dummy populated from
slots). State is only allocated later, in alloc_state, called from
PyModule_Exec:
// Objects/moduleobject.c
static int
alloc_state(PyObject *module)
{
PyModuleObject *md = (PyModuleObject*)module;
if (md->md_state_size >= 0) {
if (md->md_state == NULL) {
md->md_state = PyMem_Malloc(md->md_state_size);
if (!md->md_state) {
PyErr_NoMemory();
return -1;
}
memset(md->md_state, 0, md->md_state_size);
}
}
return 0;
}The GC callbacks guard against calling m_traverse/m_clear/m_free when
state is unallocated:
// Objects/moduleobject.c
static int
module_traverse(PyObject *self, visitproc visit, void *arg)
{
PyModuleObject *m = _PyModule_CAST(self);
if (m->md_state_traverse && (m->md_state_size <= 0 || m->md_state != NULL)) {
int res = m->md_state_traverse((PyObject*)m, visit, arg);
if (res)
return res;
}
Py_VISIT(m->md_dict);
return 0;
}PyModule_GetState exposes md_state to the extension; PyModule_GetStateSize
exposes md_state_size. New in 3.15, PyModule_GetToken exposes md_token,
which lets an extension identify its own defining token without going through
the def.
Slots and PyModuleDef
PEP 489/573 multi-phase init uses slots. A module can be created from a
PySlot array plus a spec:
// Include/moduleobject.h
PyAPI_FUNC(PyObject *) PyModule_FromSlotsAndSpec(const PySlot *slots,
PyObject *spec);
PyAPI_FUNC(int) PyModule_Exec(PyObject *module);module_from_slots_and_spec walks the slots, collecting Py_mod_create,
Py_mod_exec, Py_mod_state_size, Py_mod_state_traverse, etc. into a
def_like (a PyModuleDef), then either calls the Py_mod_create function
or falls back to PyModule_NewObject. After creation it copies the collected
members onto the module and sets md_exec to the exec function. PyModule_Exec
then allocates state and runs md_exec.
Legacy single-phase modules use PyModule_Create2 / _PyModule_CreateInitialized,
which allocates state up front and adds methods/docstring immediately.
spec
__spec__ is a ModuleSpec stored in the dict, set by importlib during
loading. The module object itself does not hold it as a field; it is just a dict
entry. C code reads it through helpers like _PyModuleSpec_IsInitializing,
_PyModuleSpec_GetFileOrigin, and _PyModuleSpec_IsUninitializedSubmodule:
// Objects/moduleobject.c
int
_PyModuleSpec_IsInitializing(PyObject *spec)
{
if (spec == NULL) {
return 0;
}
PyObject *value;
int rc = PyObject_GetOptionalAttr(spec, &_Py_ID(_initializing), &value);
if (rc > 0) {
rc = PyObject_IsTrue(value);
Py_DECREF(value);
}
return rc;
}__file__, __package__, __loader__ are likewise just dict entries, read
via _PyModule_GetFilenameObject etc.
Lazy imports and the module dict
3.15’s lazy imports interact with modules in two ways:
Lazy submodules. When
import a.bis deferred,a’s dict may contain a pending registration;_Py_module_getattrocalls_PyImport_TryLoadLazySubmoduleto materialize it on access.PyLazyImportplaceholders. Alazy import jsonstatement stores aPyLazyImportobject in the module’s globals. The type is defined inObjects/lazyimportobject.c:
// Objects/lazyimportobject.c
typedef struct {
PyObject_HEAD
PyObject *lz_builtins; // Roots own the mapping; projections retain the root.
PyObject *lz_from; // absolute name (root) or source placeholder (projection)
PyObject *lz_attr; // fromlist (root) or attribute name (projection)
PyCodeObject *lz_code; // declaration location
int lz_instr_offset;
int lz_active; // root was accessed -> import semantics required
} PyLazyImportObject;When the module dict is modified and a lazy value appears, a watcher clears the dict’s keys version so cached lookups are invalidated:
// Objects/moduleobject.c
static int
module_dict_watcher(PyDict_WatchEvent event, PyObject *dict,
PyObject *key, PyObject *new_value)
{
if (event == PyDict_EVENT_MODIFIED &&
new_value != NULL &&
PyLazyImport_CheckExact(new_value)) {
_PyDict_ClearKeysVersionLockHeld(dict);
}
return 0;
}On module deallocation, module_dealloc frees state, clears the dict, and calls
tp_free:
// Objects/moduleobject.c
static void
module_dealloc(PyObject *self)
{
PyModuleObject *m = _PyModule_CAST(self);
PyObject_GC_UnTrack(m);
FT_CLEAR_WEAKREFS(self, m->md_weaklist);
if (m->md_state_free && (m->md_state_size <= 0 || m->md_state != NULL)) {
m->md_state_free(m);
}
Py_XDECREF(m->md_dict);
Py_XDECREF(m->md_name);
if (m->md_state != NULL) {
PyMem_Free(m->md_state);
}
Py_TYPE(m)->tp_free((PyObject *)m);
}Memory layout summary
PyModuleObject
+-----------------------------+
| ob_refcnt / ob_type | PyObject_HEAD
+-----------------------------+
| md_dict -> PyDictObject| the namespace (__dict__)
+-----------------------------+
| md_state -> void* | per-module C state (or NULL)
+-----------------------------+
| md_weaklist |
+-----------------------------+
| md_name | cached __name__ for logging
+-----------------------------+
| md_token_is_def (bool) |
+-----------------------------+
| md_requires_gil (free-threaded only)
+-----------------------------+
| md_state_size |
| md_state_traverse |
| md_state_clear |
| md_state_free |
+-----------------------------+
| md_token | PyModuleDef* or arbitrary token
+-----------------------------+
| md_exec | exec func (slots path)
+-----------------------------+Key takeaways
- A module is a
PyModuleObject:PyObject_HEADplusmd_dict(the namespace),md_state(per-module C state), and bookkeeping/token fields. __dict__is a real dict field (tp_dictoffset), seeded with the standard dunder keys;PyModule_GetDictborrows it.- Per-module state is described by
PyModuleDef(m_size/m_traverse/m_clear/m_free) or by slots; it is allocated lazily for the slots path. __spec__,__file__,__package__are dict entries, not struct fields.- Attribute lookup goes through
_Py_module_getattro, which materializes lazy submodules and reifiesPyLazyImportplaceholders on first access. - Modules are GC-tracked; clearing zeroes underscore-prefixed names first, then
everything except
__builtins__.
Further reading
Objects/moduleobject.c–PyModuleObject,PyModule_Type, creation, state, slots, getattro, dealloc.Include/internal/pycore_moduleobject.h– struct and inline helpers.Include/moduleobject.h–PyModuleDef, slots, public API.Objects/lazyimportobject.c–PyLazyImportplaceholders.Python/import.c– module loading, lazy submodules.