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:
// Include/cpython/cellobject.h
typedef struct {
PyObject_HEAD
/* Content of the cell or NULL when empty */
PyObject *ob_ref;
} PyCellObject;Invariants:
ob_refis either a real object orNULL.NULLmeans the cell is empty, which is a legitimate state (an uninitializednonlocal, or a cell created withcell()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_Typeis 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.
// 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
// 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:
objmay beNULL; 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:
// 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:
// 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:
// 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_SwapTakeRefsteals a reference tovalue(no incref) and returns the previous contents; the caller is responsible forPy_DECREFof that old value. This is the building block forSTORE_DEREFandDELETE_DEREF.PyCell_SetTakeRefwraps it and decrefs the old value for you.PyCell_GetRefreturns a new reference under the cell’s critical section, using an atomic incref in the free-threaded build._PyCell_GetStackRefis a further variant that returns a_PyStackRef(used byLOAD_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:
// 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:
// 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_richcomparecompares contents: it fetches both cells’ob_ref(new references) and delegates toPyObject_RichCompare. Empty cells compare as equal to each other and sort before any non-empty cell.cell_reprformats<cell at 0x...: empty>or<cell at 0x...: int object at 0x...>.cell_traversevisitsop->ob_ref;cell_cleardoesPy_CLEAR.- Deallocation untracks, decrefs
ob_ref, and frees withPyObject_GC_Del(no freelist).
// 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:
def outer():
x = 0
def inner():
nonlocal x
x += 1
inner()
return xx 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:
// 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:
// 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
// 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.
The function/closure link
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:
// 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:
// 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
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.CellTypeisPyCell_Type, a GC-tracked struct with one field,ob_ref, which isNULLwhen the cell is empty.PyCell_Newboxes (not consumes) its argument and tracks the cell;cell()accepts zero or one argument.PyCell_GET/PyCell_SETare fast, critical-section-protected, refcount-neutral accessors;PyCell_Get/PyCell_Setare the refcount-correct public forms;PyCell_GetRef/PyCell_SetTakeRef/PyCell_SwapTakeRefare the internal ownership-transferring helpers.- An empty cell’s
.cell_contentsraisesValueError;delon a closure variable empties the cell and raisesUnboundLocalErrorif already empty. - Equality compares contents; repr shows the boxed object or “empty”.
- Closure sharing works because
MAKE_CELLboxes a local into a cell,COPY_FREE_VARShands the same cell to nested frames, andLOAD_DEREF/STORE_DEREFread and write through that shared box. function.__closure__is a tuple of cells, validated to contain only cells whose count matchesco_nfreevars.PyCell_Typeis immortal/pre-initialized and exported astypes.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 andPyCell_GET/PyCell_SETmacros.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– fallbackCellTypedefinition.Lib/test/test_types.pyandLib/test/test_funcattrs.py– reference behavior and closure edge cases.