CPython's SimpleNamespace Implementation in C

types.SimpleNamespace as the C type _PyNamespace_Type: the _PyNamespaceObject struct, its backing dict exposed as __dict__, tp_new/tp_init, generic attribute access, repr, equality, __reduce__, __replace__, GC, and the _PyNamespace_New helper traced through CPython 3.15.

Python’s types.SimpleNamespace looks like a tiny “bag of attributes” class, but in CPython 3.15 it is a real C type, _PyNamespace_Type, defined in Objects/namespaceobject.c. There is no separate attribute storage: every attribute lives in an ordinary dict that is also the object’s __dict__. This article traces that implementation.

The struct

The whole object is a PyObject_HEAD plus one pointer:

c
// Objects/namespaceobject.c
typedef struct {
    PyObject_HEAD
    PyObject *ns_dict;
} _PyNamespaceObject;

That single ns_dict is the entire state. __dict__ is not a separate property layered on top; it is ns_dict. This is exposed twice in the type table: once as a read-only member and once as tp_dictoffset:

c
// Objects/namespaceobject.c
static PyMemberDef namespace_members[] = {
    {"__dict__", _Py_T_OBJECT, offsetof(_PyNamespaceObject, ns_dict), Py_READONLY},
    {NULL}
};
c
// Objects/namespaceobject.c (in _PyNamespace_Type)
    offsetof(_PyNamespaceObject, ns_dict),      /* tp_dictoffset */

Because tp_dictoffset points at ns_dict, CPython’s generic machinery treats that slot as the instance dictionary. __dict__ access through the member definition and through tp_dictoffset therefore return the same object, which keeps obj.__dict__, vars(obj), attribute access, and pickling all consistent.

The type table

_PyNamespace_Type is the C object behind types.SimpleNamespace. Its important slots are:

c
// Objects/namespaceobject.c
PyTypeObject _PyNamespace_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "types.SimpleNamespace",                    /* tp_name */
    sizeof(_PyNamespaceObject),                 /* tp_basicsize */
    0,                                          /* tp_itemsize */
    namespace_dealloc,                          /* tp_dealloc */
    ...
    namespace_repr,                             /* tp_repr */
    ...
    PyObject_GenericGetAttr,                    /* tp_getattro */
    PyObject_GenericSetAttr,                    /* tp_setattro */
    ...
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
        Py_TPFLAGS_BASETYPE,                    /* tp_flags */
    ...
    namespace_traverse,                         /* tp_traverse */
    namespace_clear,                            /* tp_clear */
    namespace_richcompare,                      /* tp_richcompare */
    ...
    namespace_methods,                          /* tp_methods */
    namespace_members,                          /* tp_members */
    ...
    offsetof(_PyNamespaceObject, ns_dict),      /* tp_dictoffset */
    namespace_init,                             /* tp_init */
    PyType_GenericAlloc,                        /* tp_alloc */
    namespace_new,                              /* tp_new */
    PyObject_GC_Del,                            /* tp_free */
};

Three choices matter. Py_TPFLAGS_BASETYPE is set, so SimpleNamespace can be subclassed (the doc example class AdvancedNamespace(types.SimpleNamespace) works). Py_TPFLAGS_HAVE_GC is set because the instance holds references to arbitrary objects in its dict. Attribute access is delegated to PyObject_GenericGetAttr and PyObject_GenericSetAttr, which look things up in __dict__; there is no custom tp_getattr/tp_setattr, so the dict is the single source of truth.

The type is exported to Python from Modules/_typesmodule.c:

c
// Modules/_typesmodule.c
EXPORT_STATIC_TYPE("SimpleNamespace", _PyNamespace_Type);

and, in a build without the C extension, Lib/types.py falls back to SimpleNamespace = type(sys.implementation), since sys.implementation is itself a SimpleNamespace.

Allocation and initialization

namespace_new allocates the object and immediately creates an empty dict:

c
// Objects/namespaceobject.c
static PyObject *
namespace_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    PyObject *self;

    assert(type != NULL && type->tp_alloc != NULL);
    self = type->tp_alloc(type, 0);
    if (self != NULL) {
        _PyNamespaceObject *ns = (_PyNamespaceObject *)self;
        ns->ns_dict = PyDict_New();
        if (ns->ns_dict == NULL) {
            Py_DECREF(ns);
            return NULL;
        }
    }
    return self;
}

namespace_init implements the documented constructor signature SimpleNamespace(mapping_or_iterable=(), /, **kwargs):

c
// Objects/namespaceobject.c
static int
namespace_init(PyObject *op, PyObject *args, PyObject *kwds)
{
    _PyNamespaceObject *ns = _PyNamespace_CAST(op);
    PyObject *arg = NULL;
    if (!PyArg_UnpackTuple(args, _PyType_Name(Py_TYPE(ns)), 0, 1, &arg)) {
        return -1;
    }
    if (arg != NULL) {
        PyObject *dict;
        if (PyDict_CheckExact(arg)) {
            dict = Py_NewRef(arg);
        }
        else {
            dict = PyObject_CallOneArg((PyObject *)&PyDict_Type, arg);
            if (dict == NULL) {
                return -1;
            }
        }
        int err = (!PyArg_ValidateKeywordArguments(dict) ||
                   PyDict_Update(ns->ns_dict, dict) < 0);
        Py_DECREF(dict);
        if (err) {
            return -1;
        }
    }
    if (kwds == NULL) {
        return 0;
    }
    if (!PyArg_ValidateKeywordArguments(kwds)) {
        return -1;
    }
    return PyDict_Update(ns->ns_dict, kwds);
}

A positional argument is either used directly (if it is exactly a dict) or converted with dict(arg); every key must be a string, enforced by PyArg_ValidateKeywordArguments. Then PyDict_Update merges it into ns_dict, and keyword arguments are merged the same way. Because the backing store is a normal dict, insertion order is preserved and drives the repr order.

Repr

The repr walks the dict’s keys in insertion order and formats each as key=value!r:

c
// Objects/namespaceobject.c
static PyObject *
namespace_repr(PyObject *ns)
{
    ...
    keys = PyDict_Keys(d);
    ...
    while ((key = PyIter_Next(keys_iter)) != NULL) {
        if (PyUnicode_Check(key) && PyUnicode_GET_LENGTH(key) > 0) {
            PyObject *value, *item;

            int has_key = PyDict_GetItemRef(d, key, &value);
            if (has_key == 1) {
                item = PyUnicode_FromFormat("%U=%R", key, value);
                Py_DECREF(value);
                ...
            }
            else if (has_key < 0) {
                loop_error = 1;
            }
        }
        ...
    }
    ...
    repr = PyUnicode_FromFormat("%s(%S)", name, pairsrepr);
}

The name is "namespace" for an exact _PyNamespace_Type instance and Py_TYPE(ns)->tp_name for subclasses, so a subclass repr uses its own class name. PyDict_GetItemRef (rather than the older PyDict_GetItemWithError) is used so the borrowed reference is safely converted to an owned one before formatting, which is what fixed a crash in 3.15 when the dict mutated during repr.

Equality

Equality compares the backing dicts directly, but only for ==/!= and only when both operands are namespace objects:

c
// Objects/namespaceobject.c
static PyObject *
namespace_richcompare(PyObject *self, PyObject *other, int op)
{
    if (
        (op == Py_EQ || op == Py_NE) &&
        PyObject_TypeCheck(self, &_PyNamespace_Type) &&
        PyObject_TypeCheck(other, &_PyNamespace_Type)
    ) {
        return PyObject_RichCompare(((_PyNamespaceObject *)self)->ns_dict,
                                   ((_PyNamespaceObject *)other)->ns_dict, op);
    }
    Py_RETURN_NOTIMPLEMENTED;
}

For any other comparison operator (or non-namespace operand) it returns NotImplemented, letting Python fall back to identity comparison or raise TypeError. The op == Py_EQ || op == Py_NE guard avoids passing an ordering operator to dict.__eq__.

Pickling and copying

__reduce__ returns a three-tuple (type, (), ns_dict), which makes unpickling call type() and then update its dict from the third element:

c
// Objects/namespaceobject.c
static PyObject *
namespace_reduce(PyObject *op, PyObject *Py_UNUSED(ignored))
{
    _PyNamespaceObject *ns = (_PyNamespaceObject*)op;
    PyObject *result, *args = PyTuple_New(0);

    if (!args)
        return NULL;

    result = PyTuple_Pack(3, (PyObject *)Py_TYPE(ns), args, ns->ns_dict);
    Py_DECREF(args);
    return result;
}

__replace__, used by copy.replace, allocates a new instance of the same type, copies the old dict in, then applies the keyword overrides:

c
// Objects/namespaceobject.c
static PyObject *
namespace_replace(PyObject *self, PyObject *args, PyObject *kwargs)
{
    if (!_PyArg_NoPositional("__replace__", args)) {
        return NULL;
    }

    PyObject *result = PyObject_CallNoArgs((PyObject *)Py_TYPE(self));
    if (!result) {
        return NULL;
    }
    if (!_PyNamespace_Check(result)) {
        PyErr_Format(PyExc_TypeError,
                     "expect %N type, but %T() returned '%T' object",
                     &_PyNamespace_Type, self, result);
        Py_DECREF(result);
        return NULL;
    }

    if (PyDict_Update(((_PyNamespaceObject*)result)->ns_dict,
                      ((_PyNamespaceObject*)self)->ns_dict) < 0)
    {
        Py_DECREF(result);
        return NULL;
    }
    if (kwargs) {
        if (PyDict_Update(((_PyNamespaceObject*)result)->ns_dict, kwargs) < 0) {
            Py_DECREF(result);
            return NULL;
        }
    }
    return result;
}

The _PyNamespace_Check(result) guard matters because Py_TYPE(self) could be a subclass whose __new__/__init__ returns something that is not a namespace; without it the subsequent cast of result would be undefined behavior. This guard was added in 3.15.

GC, clear, and dealloc

Because ns_dict can reference arbitrary objects (including cycles), the type participates in the cyclic GC:

c
// Objects/namespaceobject.c
static int
namespace_traverse(PyObject *op, visitproc visit, void *arg)
{
    _PyNamespaceObject *ns = _PyNamespace_CAST(op);
    Py_VISIT(ns->ns_dict);
    return 0;
}

static int
namespace_clear(PyObject *op)
{
    _PyNamespaceObject *ns = _PyNamespace_CAST(op);
    Py_CLEAR(ns->ns_dict);
    return 0;
}

static void
namespace_dealloc(PyObject *op)
{
    _PyNamespaceObject *ns = _PyNamespace_CAST(op);
    PyObject_GC_UnTrack(ns);
    Py_CLEAR(ns->ns_dict);
    Py_TYPE(ns)->tp_free((PyObject *)ns);
}

tp_free is PyObject_GC_Del, matching tp_alloc = PyType_GenericAlloc for a GC-tracked type.

The internal helper

CPython itself creates SimpleNamespaces in many places (module specs, sys.implementation, interpreter configs, instrumentation events, etc.) through a small helper that skips __init__:

c
// Objects/namespaceobject.c
PyObject *
_PyNamespace_New(PyObject *kwds)
{
    PyObject *ns = namespace_new(&_PyNamespace_Type, NULL, NULL);
    if (ns == NULL)
        return NULL;

    if (kwds == NULL)
        return ns;
    if (PyDict_Update(((_PyNamespaceObject *)ns)->ns_dict, kwds) != 0) {
        Py_DECREF(ns);
        return NULL;
    }

    return (PyObject *)ns;
}

It calls namespace_new directly (allocating and creating the empty dict) and then merges kwds. Passing NULL yields an empty namespace. This is the function exported by Include/internal/pycore_namespace.h and used by import.c, sysmodule.c, timemodule.c, _interpretersmodule.c, and others.

Summary

types.SimpleNamespace is deliberately minimal: a PyObject_HEAD plus a dict. Attribute access, __dict__, GC, and instance dictionary handling are all inherited from CPython’s generic object machinery, while the type supplies allocation, initialization from a mapping/iterable plus kwargs, repr, equality, pickling, and __replace__. Every attribute is just a key in ns_dict, which is why SimpleNamespace behaves like a mutable, attribute-accessible dict.

PythonCpythonInternalsNamespace