How CPython Implements Cell (types.CellType) in C

cell (types.CellType) as a GC-tracked container holding a single PyObject* (ob_ref), PyCell_New, PyCell_Get/Set and the refcount/ownership rules, critical sections for free-threading, empty cells, and how closure variables are shared between enclosing and nested functions via MAKE_CELL/COPY_FREE_VARS/LOAD_DEREF/STORE_DEREF, traced through the CPython 3.15 source.

Python’s cell – the object exposed as types.CellType and the thing that makes nonlocal (and closures) work – is implemented in CPython in Objects/cellobject.c as a tiny GC-tracked struct holding a single pointer: ob_ref. A cell is a mutable box around one PyObject *. Two functions that close over the same variable do not copy the value; they share the same cell, and reading/writing the variable goes through LOAD_DEREF/STORE_DEREF on that shared box. This article traces that implementation through the 3.15 source.

The object: PyCellObject

The layout is defined (non-limited API only) in Include/cpython/cellobject.h:

c
// Include/cpython/cellobject.h
typedef struct {
    PyObject_HEAD
    /* Content of the cell or NULL when empty */
    PyObject *ob_ref;
} PyCellObject;

Invariants:

  • ob_ref is either a real object or NULL. NULL means the cell is empty, which is a legitimate state (an uninitialized nonlocal, or a cell created with cell() and no argument).
  • PyCell_Check(op) is an exact type check (Py_IS_TYPE((op), &PyCell_Type)), so subclasses are not cells for C-level fast paths.
  • The object is GC-tracked (Py_TPFLAGS_HAVE_GC) because it owns one reference.
  • PyCell_Type is one of the pre-initialized static types, so the type object itself is immortal.
  • There is no freelist: every cell goes through PyObject_GC_New / PyObject_GC_Del.

The C-level accessors split into two families: macros that are refcount-neutral and functions that transfer ownership.

c
// Include/cpython/cellobject.h
static inline PyObject* PyCell_GET(PyObject *op) {
    PyObject *res;
    PyCellObject *cell;
    assert(PyCell_Check(op));
    cell = _Py_CAST(PyCellObject*, op);
    Py_BEGIN_CRITICAL_SECTION(cell);
    res = cell->ob_ref;          /* borrowed: no incref */
    Py_END_CRITICAL_SECTION();
    return res;
}

static inline void PyCell_SET(PyObject *op, PyObject *value) {
    PyCellObject *cell;
    assert(PyCell_Check(op));
    cell = _Py_CAST(PyCellObject*, op);
    Py_BEGIN_CRITICAL_SECTION(cell);
    cell->ob_ref = value;        /* stores the pointer; no incref/decref */
    Py_END_CRITICAL_SECTION();
}

PyCell_GET/PyCell_SET are the fast, lock-protected, refcount-neutral forms used inside the interpreter. They do not change reference counts, so callers must manage ownership themselves. The Py_BEGIN_CRITICAL_SECTION pair makes these safe under the free-threaded (no-GIL) build, where the cell’s contents can be read and written concurrently.

Construction: PyCell_New

c
// Objects/cellobject.c
PyObject *
PyCell_New(PyObject *obj)
{
    PyCellObject *op;

    op = (PyCellObject *)PyObject_GC_New(PyCellObject, &PyCell_Type);
    if (op == NULL)
        return NULL;
    op->ob_ref = Py_XNewRef(obj);   /* NULL-safe incref */

    _PyObject_GC_TRACK(op);
    return (PyObject *)op;
}

Notes:

  • obj may be NULL; the resulting cell is empty. This is how the VM creates cells for variables that have not yet been assigned.
  • The argument is re-referenced (not consumed) via Py_XNewRef.
  • The cell is tracked by the GC immediately after initialization.

The Python-facing constructor is cell.__new__ (clinic-generated cell_new), which accepts zero or one positional argument:

c
// Objects/cellobject.c
static PyObject *
cell_new(PyTypeObject *type, PyObject *args, PyObject *kwargs)
{
    PyObject *obj = NULL;
    if (!_PyArg_NoKeywords("cell", kwargs)) {
        goto exit;
    }
    if (!PyArg_UnpackTuple(args, "cell", 0, 1, &obj)) {
        goto exit;
    }
    return PyCell_New(obj);
    ...
}

So types.CellType() makes an empty cell and types.CellType(x) boxes x (without consuming the caller’s reference).

Reading and writing: PyCell_Get / PyCell_Set

The public C API is refcount-correct on both sides:

c
// Objects/cellobject.c
PyObject *
PyCell_Get(PyObject *op)
{
    if (!PyCell_Check(op)) {
        PyErr_BadInternalCall();
        return NULL;
    }
    return PyCell_GetRef((PyCellObject *)op);   /* new reference */
}

int
PyCell_Set(PyObject *op, PyObject *value)
{
    if (!PyCell_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
    PyCell_SetTakeRef((PyCellObject *)op, Py_XNewRef(value));
    return 0;
}

PyCell_Get returns a new reference (or NULL for an empty cell, with no exception set). PyCell_Set does not steal the caller’s reference: it increfs value internally and returns 0 on success.

The internal helpers in Include/internal/pycore_cell.h are the ones the interpreter and free-threading code actually use, and they have different ownership rules:

c
// Include/internal/pycore_cell.h

// Sets the cell contents to `value` and returns the previous contents.
// Steals a reference to `value`.
static inline PyObject *
PyCell_SwapTakeRef(PyCellObject *cell, PyObject *value)
{
    PyObject *old_value;
    Py_BEGIN_CRITICAL_SECTION(cell);
    old_value = cell->ob_ref;
    FT_ATOMIC_STORE_PTR_RELEASE(cell->ob_ref, value);
    Py_END_CRITICAL_SECTION();
    return old_value;          /* caller owns and must decref */
}

static inline void
PyCell_SetTakeRef(PyCellObject *cell, PyObject *value)
{
    PyObject *old_value = PyCell_SwapTakeRef(cell, value);
    Py_XDECREF(old_value);
}

// Gets the cell contents. Returns a new reference.
static inline PyObject *
PyCell_GetRef(PyCellObject *cell)
{
    PyObject *res;
    Py_BEGIN_CRITICAL_SECTION(cell);
#ifdef Py_GIL_DISABLED
    res = _Py_XNewRefWithLock(cell->ob_ref);
#else
    res = Py_XNewRef(cell->ob_ref);
#endif
    Py_END_CRITICAL_SECTION();
    return res;                /* NULL if empty; no exception */
}

Key points:

  • PyCell_SwapTakeRef steals a reference to value (no incref) and returns the previous contents; the caller is responsible for Py_DECREF of that old value. This is the building block for STORE_DEREF and DELETE_DEREF.
  • PyCell_SetTakeRef wraps it and decrefs the old value for you.
  • PyCell_GetRef returns a new reference under the cell’s critical section, using an atomic incref in the free-threaded build.
  • _PyCell_GetStackRef is a further variant that returns a _PyStackRef (used by LOAD_DEREF) and can avoid an allocation on the free-threaded path via _Py_TryIncrefCompareStackRef.

The Python-level read/write goes through the cell_contents property:

c
// Objects/cellobject.c
static PyObject *
cell_get_contents(PyObject *self, void *closure)
{
    PyCellObject *op = _PyCell_CAST(self);
    PyObject *res = PyCell_GetRef(op);
    if (res == NULL) {
        PyErr_SetString(PyExc_ValueError, "Cell is empty");
        return NULL;
    }
    return res;
}

static int
cell_set_contents(PyObject *self, PyObject *obj, void *Py_UNUSED(ignored))
{
    PyCellObject *cell = _PyCell_CAST(self);
    Py_XINCREF(obj);                          /* keep our own ref */
    PyCell_SetTakeRef((PyCellObject *)cell, obj);  /* steals it */
    return 0;
}

So reading an empty cell’s .cell_contents raises ValueError: Cell is empty, and assigning .cell_contents = x replaces the boxed value (and drops the reference to the previous one).

Empty cells and deletion

DELETE_DEREF empties a cell by swapping in NULL:

c
// Python/bytecodes.c (DELETE_DEREF)
inst(DELETE_DEREF, (--)) {
    PyObject *cell = PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg));
    PyObject *oldobj = PyCell_SwapTakeRef((PyCellObject *)cell, NULL);
    if (oldobj == NULL) {
        _PyEval_FormatExcUnbound(tstate, _PyFrame_GetCode(frame), oparg);
        ERROR_NO_POP();
    }
    Py_DECREF(oldobj);
}

If the cell was already empty, PyCell_SwapTakeRef returns NULL and the interpreter raises UnboundLocalError. This is the implementation of del on a closure variable.

Equality, repr, and GC

  • cell_richcompare compares contents: it fetches both cells’ ob_ref (new references) and delegates to PyObject_RichCompare. Empty cells compare as equal to each other and sort before any non-empty cell.
  • cell_repr formats <cell at 0x...: empty> or <cell at 0x...: int object at 0x...>.
  • cell_traverse visits op->ob_ref; cell_clear does Py_CLEAR.
  • Deallocation untracks, decrefs ob_ref, and frees with PyObject_GC_Del (no freelist).
c
// Objects/cellobject.c
static void
cell_dealloc(PyObject *self)
{
    PyCellObject *op = _PyCell_CAST(self);
    _PyObject_GC_UNTRACK(op);
    Py_XDECREF(op->ob_ref);
    PyObject_GC_Del(op);
}

Closure variable sharing

This is the reason cells exist. Consider:

python
def outer():
    x = 0
    def inner():
        nonlocal x
        x += 1
    inner()
    return x

x in outer and x in inner are the same cell. The mechanism, in three steps:

1. The enclosing frame makes a cell: MAKE_CELL

outer’s locals that are referenced by a nested function are turned into cells. The bytecode MAKE_CELL replaces the value currently in a local slot with a cell that boxes it:

c
// Python/bytecodes.c (MAKE_CELL)
inst(MAKE_CELL, (--)) {
    PyObject *initial = PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg));
    PyObject *cell = PyCell_New(initial);
    if (cell == NULL) {
        ERROR_NO_POP();
    }
    _PyStackRef tmp = GETLOCAL(oparg);
    GETLOCAL(oparg) = PyStackRef_FromPyObjectSteal(cell);
    PyStackRef_XCLOSE(tmp);
}

The cell is now stored in outer’s frame slot; the original value is boxed inside it via PyCell_New. initial may be NULL for a variable that has not yet been bound.

2. The nested function receives the same cells: COPY_FREE_VARS

A function object carries its closure as a tuple of cells in func_closure (validated by function.__new__ to contain only cells). When inner is called, COPY_FREE_VARS copies those cell references into the new frame’s free-variable slots:

c
// Python/bytecodes.c (COPY_FREE_VARS)
inst(COPY_FREE_VARS, (--)) {
    PyCodeObject *co = _PyFrame_GetCode(frame);
    PyFunctionObject *func =
        (PyFunctionObject *)PyStackRef_AsPyObjectBorrow(frame->f_funcobj);
    PyObject *closure = func->func_closure;
    assert(oparg == co->co_nfreevars);
    int offset = co->co_nlocalsplus - oparg;
    for (int i = 0; i < oparg; ++i) {
        PyObject *o = PyTuple_GET_ITEM(closure, i);
        frame->localsplus[offset + i] =
            PyStackRef_FromPyObjectNew(o);   /* same cell object, new ref */
    }
}

Crucially, the frame does not copy the value – it copies a reference to the same PyCellObject. So outer’s slot and inner’s free-var slot point at one shared box. This is what makes nonlocal mutations visible to the enclosing scope.

3. Reads and writes go through the cell: LOAD_DEREF / STORE_DEREF

c
// Python/bytecodes.c
inst(LOAD_DEREF, ( -- value)) {
    PyCellObject *cell =
        (PyCellObject *)PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg));
    value = _PyCell_GetStackRef(cell);
    if (PyStackRef_IsNull(value)) {
        _PyEval_FormatExcUnbound(tstate, _PyFrame_GetCode(frame), oparg);
        ERROR_IF(true);
    }
}

inst(STORE_DEREF, (v --)) {
    PyCellObject *cell =
        (PyCellObject *)PyStackRef_AsPyObjectBorrow(GETLOCAL(oparg));
    PyCell_SetTakeRef(cell, PyStackRef_AsPyObjectSteal(v));
}

LOAD_DEREF unwraps the cell (returning UnboundLocalError if empty); STORE_DEREF replaces the boxed value via PyCell_SetTakeRef, stealing the incoming reference and dropping the old one. Because both scopes reference the same cell, a store in inner is immediately visible to outer.

PyFunctionObject.func_closure is a tuple of cells (or NULL when there are no free variables). function.__new__ enforces the invariant that every element is a cell and that the count matches co_nfreevars:

c
// Objects/funcobject.c (func_new_impl, excerpt)
nclosure = closure == Py_None ? 0 : PyTuple_GET_SIZE(closure);
if (code->co_nfreevars != nclosure) { ... }
if (nclosure) {
    for (i = 0; i < nclosure; i++) {
        PyObject *o = PyTuple_GET_ITEM(closure, i);
        if (!PyCell_Check(o)) { ... }   /* must be a cell */
    }
}

You can observe this from Python: f.__closure__ is a tuple of types.CellType instances, and f.__closure__[i].cell_contents reads the captured value.

Cross-scope sharing in practice

When a nested function is created, the compiler emits code that builds its func_closure tuple from the enclosing frame’s cell slots (via BUILD_TUPLE over the free variables). The resulting function object holds references to the same cell objects, so mutations flow both ways: nonlocal writes update the shared box, and reads in either scope see the latest value. Comprehensions and lambdas use the same machinery, which is why late-binding closures in loops capture the variable rather than its value at definition time.

Types module export

types.CellType is just PyCell_Type, exported from the _types extension:

c
// Modules/_typesmodule.c
EXPORT_STATIC_TYPE("CellType", PyCell_Type);

In pure-Python fallback mode (types.py), it is derived as type(_cell_factory().__closure__[0]), where _cell_factory contains a nonlocal to force cell creation.

Memory layout summary

text
PyCellObject
+---------------------------+
| ob_refcnt / ob_tid        |
+---------------------------+
| *ob_type  (&PyCell_Type)  |
+---------------------------+
| *ob_ref                   |  -> boxed value, or NULL if empty
+---------------------------+

Fixed size (sizeof(PyCellObject)), GC-tracked, no freelist. The single payload pointer is the entire state: sharing a closure variable is just sharing the pointer to this box, and nonlocal assignment is PyCell_SetTakeRef on the shared box.

Key takeaways

  • types.CellType is PyCell_Type, a GC-tracked struct with one field, ob_ref, which is NULL when the cell is empty.
  • PyCell_New boxes (not consumes) its argument and tracks the cell; cell() accepts zero or one argument.
  • PyCell_GET/PyCell_SET are fast, critical-section-protected, refcount-neutral accessors; PyCell_Get/PyCell_Set are the refcount-correct public forms; PyCell_GetRef/PyCell_SetTakeRef/ PyCell_SwapTakeRef are the internal ownership-transferring helpers.
  • An empty cell’s .cell_contents raises ValueError; del on a closure variable empties the cell and raises UnboundLocalError if already empty.
  • Equality compares contents; repr shows the boxed object or “empty”.
  • Closure sharing works because MAKE_CELL boxes a local into a cell, COPY_FREE_VARS hands the same cell to nested frames, and LOAD_DEREF/STORE_DEREF read and write through that shared box.
  • function.__closure__ is a tuple of cells, validated to contain only cells whose count matches co_nfreevars.
  • PyCell_Type is immortal/pre-initialized and exported as types.CellType.

Further reading

  • Objects/cellobject.c – PyCellObject, PyCell_New, cell_get_contents, cell_set_contents, dealloc/traverse/clear, richcompare, repr.
  • Include/cpython/cellobject.h – struct layout and PyCell_GET/ PyCell_SET macros.
  • Include/internal/pycore_cell.h – PyCell_GetRef, PyCell_SetTakeRef, PyCell_SwapTakeRef, _PyCell_GetStackRef.
  • Python/bytecodes.c – MAKE_CELL, COPY_FREE_VARS, LOAD_DEREF, STORE_DEREF, DELETE_DEREF, LOAD_FROM_DICT_OR_DEREF.
  • Objects/funcobject.c – func_closure, function.__new__ closure validation.
  • Modules/_typesmodule.c – EXPORT_STATIC_TYPE("CellType", PyCell_Type).
  • Lib/types.py – fallback CellType definition.
  • Lib/test/test_types.py and Lib/test/test_funcattrs.py – reference behavior and closure edge cases.

PythonCpythonInternalsCell