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):

c
// 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_dict is the module’s namespace. It is the object you get from module.__dict__.
  • md_state / md_state_size / md_state_* are the per-module C state used by extension modules (the m_size/m_traverse/m_clear/m_free of a PyModuleDef).
  • md_token (plus md_token_is_def) records where the module came from: for a module built from a PyModuleDef it points at that def; for a module built from slots it points at the token passed via Py_mod_token (or NULL).

PyModule_Type is declared in Include/moduleobject.h and defined at the bottom of Objects/moduleobject.c:

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:

c
// 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:

c
// 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:

c
// 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:

c
// 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:

c
// 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:

  1. A lazy submodule (importlib’s pending-child machinery) can be materialized on first access via _PyImport_TryLoadLazySubmodule.
  2. If the stored value is a PyLazyImport placeholder, accessing the attribute reifies it: _PyLazyImport_Reify runs 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:

c
// 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:

c
// 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:

c
// 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:

c
// 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:

c
// 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:

c
// 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:

  1. Lazy submodules. When import a.b is deferred, a’s dict may contain a pending registration; _Py_module_getattro calls _PyImport_TryLoadLazySubmodule to materialize it on access.

  2. PyLazyImport placeholders. A lazy import json statement stores a PyLazyImport object in the module’s globals. The type is defined in Objects/lazyimportobject.c:

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:

c
// 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:

c
// 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

text
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_HEAD plus md_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_GetDict borrows 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 reifies PyLazyImport placeholders 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 – PyLazyImport placeholders.
  • Python/import.c – module loading, lazy submodules.

PythonCpythonInternalsModule