How CPython Caches Small Integers
The immortal small_ints array (-5..1024), the IMMORTALITY_BIT_MASK tag, get_small_int, maybe_small_long, LOAD_SMALL_INT, and the medium-int freelist, traced through CPython 3.15.
In CPython, a = 1; b = 1; a is b is True. That identity is not an
accident of the language semantics; it is a deliberate C-level optimization.
Every integer in the range -5 to 1024 is allocated once, at interpreter
startup, marked immortal, and reused for the rest of the process lifetime. This
article traces how that cache is built and how the hot paths use it.
The array
The cache is a static array inside _Py_static_objects, sized so that index
_PY_NSMALLNEGINTS + value lands in range for -5..1024:
// Include/internal/pycore_runtime_structs.h
#define _PY_NSMALLPOSINTS 1025
#define _PY_NSMALLNEGINTS 5
...
struct _Py_static_objects {
struct {
/* Small integers are preallocated in this array so that they
* can be shared. The integers that are preallocated are those
* in the range -_PY_NSMALLNEGINTS (inclusive) to
* _PY_NSMALLPOSINTS (exclusive). */
PyLongObject small_ints[_PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS];
...
} ...
};That is 1030 objects, covering -5 through 1024 inclusive. Access goes
through a macro that indexes into the singleton table:
// Include/internal/pycore_long.h
#define _PyLong_SMALL_INTS _Py_SINGLETON(small_ints)
#define _PY_IS_SMALL_INT(val) \
(-_PY_NSMALLNEGINTS <= (val) && (val) < _PY_NSMALLPOSINTS)The accessors are one-liners:
// Include/internal/pycore_long.h
static inline PyObject* _PyLong_GetZero(void)
{ return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS]; }
static inline PyObject* _PyLong_GetOne(void)
{ return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS+1]; }
static inline PyObject* _PyLong_FromUnsignedChar(unsigned char i)
{ return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS+i]; }How the array is initialized
The array is not filled at runtime with a loop. It is statically initialized from a generated initializer, one entry per value:
// Include/internal/pycore_runtime_init_generated.h
#define _Py_small_ints_INIT { \
_PyLong_DIGIT_INIT(-5), \
_PyLong_DIGIT_INIT(-4), \
... \
_PyLong_DIGIT_INIT(1024), \
}Each entry is a full PyLongObject with its refcount/immortality bits baked
in, so there is no startup cost beyond linking the data.
The immortal tag
Small ints are immortal, and CPython can test “is this a small int?” with a single bit test rather than a range check:
// Include/internal/pycore_long.h
#define IMMORTALITY_BIT_MASK (1 << 2)
...
static inline bool _PyLong_IsSmallInt(const PyLongObject *op) {
bool is_small_int = (op->long_value.lv_tag & IMMORTALITY_BIT_MASK) != 0;
if (is_small_int) {
assert(PyLong_CheckExact(op));
assert(_Py_IsImmortal(op));
assert((_PyLong_IsCompact(op)
&& _PY_IS_SMALL_INT(_PyLong_CompactValue(op))));
}
return is_small_int;
}The bit is set at compile time:
// Include/internal/pycore_long.h
#define _PyLong_DIGIT_INIT(val) \
{ \
.ob_base = _PyObject_HEAD_INIT(&PyLong_Type), \
.long_value = { \
.lv_tag = TAG_FROM_SIGN_AND_SIZE(...) | IMMORTALITY_BIT_MASK, \
{ ((val) >= 0 ? (val) : -(val)) }, \
} \
}So a small int is identified by a tag bit, which is also what makes it
immortal. The range check _PY_IS_SMALL_INT is only used on the construction
path, before an object exists.
The hot paths
get_small_int returns a borrowed reference to the cached object, and
maybe_small_long folds any in-range computed result back into the cache:
// Objects/longobject.c
static PyObject *get_small_int(sdigit ival) {
assert(IS_SMALL_INT(ival));
return (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + ival];
}
static PyLongObject *maybe_small_long(PyLongObject *v) {
if (v && _PyLong_IsCompact(v)) {
stwodigits ival = medium_value(v);
if (IS_SMALL_INT(ival)) {
_Py_DECREF_INT(v);
return (PyLongObject *)get_small_int((sdigit)ival);
}
}
return v;
}Every PyLong_From* path checks IS_SMALL_INT first, so creating an int in
range is O(1) and allocation-free. Arithmetic that produces a small result
discards the freshly allocated object and returns the canonical one instead.
A dedicated bytecode
There is an opcode just for loading cached non-negative ints:
// Python/bytecodes.c
replicate(4) inst(LOAD_SMALL_INT, (-- value)) {
assert(oparg < _PY_NSMALLPOSINTS);
PyObject *obj = (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + oparg];
value = PyStackRef_FromPyObjectBorrow(obj);
}Only the non-negative half (0..1024) is directly loadable this way; negative
cached ints still travel through LOAD_CONST.
The compiler is more conservative than the VM:
// Python/flowgraph.c
if (!overflow && _PY_IS_SMALL_INT(val) && 0 <= val && val <= 255) {
INSTR_SET_OP1(instr, LOAD_SMALL_INT, (int)val);
return 1;
}So the full 1024 range is available to the interpreter, but the compiler only
emits LOAD_SMALL_INT for 0..255. Larger cached ints may still arrive via
LOAD_CONST, which points at the same immortal object.
Medium ints: the freelist fallback
Single-digit ints that are not small (roughly outside -5..1024 but still
fitting in one digit) are not cached forever, but they are recycled through the
int freelist:
// Objects/longobject.c
if (ndigits == 1) {
result = (PyLongObject *)_Py_FREELIST_POP(PyLongObject, ints);
}// Objects/longobject.c
void _PyLong_ExactDealloc(PyObject *self) {
assert(PyLong_CheckExact(self));
if (_PyLong_IsSmallInt((PyLongObject *)self)) {
_Py_SetImmortal(self);
return;
}
if (_PyLong_IsCompact((PyLongObject *)self)) {
_Py_FREELIST_FREE(ints, self, PyObject_Free);
return;
}
PyObject_Free(self);
}That freelist holds 100 entries (Py_ints_MAXFREELIST). Multi-digit ints are
freed normally. The split is sensible: small ints are immortal singletons,
medium ints are recycled shells, and big ints are just allocated.
Why this matters
Integer objects are created constantly – loop counters, indices, arithmetic,
lengths. Without the cache, every i + 1 in a hot loop would allocate and
free a PyLongObject. With it, the common case is a pointer lookup and a
borrowed reference, and is identity works for free. The medium-int freelist
then covers the next tier, so even non-small single-digit arithmetic stays off
the general allocator most of the time.