CPython's slice Implementation in C: Three Objects and a Bunch of Clamping

slice is a GC-tracked object holding start, stop and step. Here's how CPython implements it in C: the freelist, PySlice_Unpack, PySlice_AdjustIndices, and the overflow-free _PySlice_GetLongIndices.

TL;DR: slice is a tiny GC-tracked object holding three arbitrary Python objects — start, stop, step — with None standing for “omitted”. Turning those into real indices happens later, in PySlice_Unpack (objects → Py_ssize_t) and PySlice_AdjustIndices (clamping to the sequence length).

Python’s slice is the object produced by a[0:10:2] and by slice(0, 10, 2). In CPython it lives in Objects/sliceobject.c as a small GC-tracked struct holding three arbitrary Python objects, where None (Py_None) stands for “omitted”. The same file also owns the immortal Ellipsis singleton. Let’s trace the implementation through the 3.15 source.

The object: PySliceObject

The public (limited-API) layout is defined in Include/cpython/sliceobject.h:

c
// Include/cpython/sliceobject.h
typedef struct {
    PyObject_HEAD
    PyObject *start, *stop, *step;      /* not NULL */
} PySliceObject;

Three invariants follow from this:

  • The three fields are never NULL. An omitted bound is stored as Py_None, which is why slice(5) produces slice(None, 5, None).
  • They are arbitrary Python objects. They need not be integers; anything with __index__ is accepted later, when the slice is actually applied.
  • PySlice_Check(op) is an exact type check (Py_IS_TYPE((op), &PySlice_Type)), so a subclass of slice is not a slice for C-level fast paths.

PySlice_Type.tp_basicsize is sizeof(PySliceObject) and tp_itemsize is 0, so the object is fixed-size. It’s GC-tracked (Py_TPFLAGS_HAVE_GC) because it owns three references.

Construction and the freelist

slice_new (the tp_new) accepts 1 to 3 positional arguments and swaps start/stop to mirror range()’s calling convention:

c
// Objects/sliceobject.c
static PyObject *
slice_new(PyTypeObject *type, PyObject *args, PyObject *kw)
{
    PyObject *start, *stop, *step;
    start = stop = step = NULL;
    if (!_PyArg_NoKeywords("slice", kw))
        return NULL;
    if (!PyArg_UnpackTuple(args, "slice", 1, 3, &start, &stop, &step))
        return NULL;
    /* This swapping of stop and start is to maintain similarity with range(). */
    if (stop == NULL) {
        stop = start;
        start = NULL;
    }
    return PySlice_New(start, stop, step);
}

PySlice_New normalizes NULLs to Py_None and delegates to _PyBuildSlice_Consume2, which pops from a tiny per-interpreter freelist before falling back to PyObject_GC_New:

c
// Objects/sliceobject.c
static PySliceObject *
_PyBuildSlice_Consume2(PyObject *start, PyObject *stop, PyObject *step)
{
    assert(start != NULL && stop != NULL && step != NULL);
    PySliceObject *obj = _Py_FREELIST_POP(PySliceObject, slices);
    if (obj == NULL) {
        obj = PyObject_GC_New(PySliceObject, &PySlice_Type);
        if (obj == NULL) {
            goto error;
        }
    }
    obj->start = start;
    obj->stop = stop;
    obj->step = Py_NewRef(step);
    _PyObject_GC_TRACK(obj);
    return obj;
error:
    Py_DECREF(start);
    Py_DECREF(stop);
    return NULL;
}

Two things worth noting:

  • start and stop are consumed (their refcounts are taken over); step is re-referenced via Py_NewRef. That asymmetry is why the bytecode BUILD_SLICE calls PySlice_New(start_o, stop_o, step_o) with borrowed references, while _PyBuildSlice_ConsumeRefs (used by STORE_SLICE and _STORE_SLICE) steals start/stop to avoid extra refcount churn.
  • The freelist holds at most Py_slices_MAXFREELIST (1) entry, so it’s essentially a single-slot cache. slice_dealloc returns the object to it via _Py_FREELIST_FREE(slices, r, PyObject_GC_Del).

_PySlice_FromIndices is a convenience constructor used when C code already has Py_ssize_t bounds and wants a slice object:

c
// Objects/sliceobject.c
PyObject *
_PySlice_FromIndices(Py_ssize_t istart, Py_ssize_t istop)
{
    PyObject *start, *end, *slice;
    start = PyLong_FromSsize_t(istart);
    ...
    slice = PySlice_New(start, end, NULL);  /* step defaults to None -> 1 */
    ...
}

BUILD_SLICE in the bytecode

The compiler emits BUILD_SLICE for subscript expressions like a[i:j:k]:

c
// Python/bytecodes.c
inst(BUILD_SLICE, (args[oparg] -- slice)) {
    PyObject *start_o = PyStackRef_AsPyObjectBorrow(args[0]);
    PyObject *stop_o  = PyStackRef_AsPyObjectBorrow(args[1]);
    PyObject *step_o  = oparg == 3 ? PyStackRef_AsPyObjectBorrow(args[2]) : NULL;
    PyObject *slice_o = PySlice_New(start_o, stop_o, step_o);
    DECREF_INPUTS();
    ERROR_IF(slice_o == NULL);
    slice = PyStackRef_FromPyObjectStealMortal(slice_o);
}

oparg is 2 or 3 depending on whether a step was given, and None is pushed for omitted bounds, so PySlice_New always receives non-NULL values.

Unpacking a slice: PySlice_Unpack

Before a slice can be applied, its three PyObject * fields must become Py_ssize_t values. PySlice_Unpack does that, treating None as a sentinel and clamping/boosting to PY_SSIZE_T_MIN/PY_SSIZE_T_MAX:

c
// Objects/sliceobject.c
int
PySlice_Unpack(PyObject *_r,
               Py_ssize_t *start, Py_ssize_t *stop, Py_ssize_t *step)
{
    PySliceObject *r = (PySliceObject*)_r;
    if (r->step == Py_None) {
        *step = 1;
    } else {
        if (!_PyEval_SliceIndex(r->step, step)) return -1;
        if (*step == 0) {
            PyErr_SetString(PyExc_ValueError, "slice step cannot be zero");
            return -1;
        }
        /* Replace -PY_SSIZE_T_MAX-1 with -PY_SSIZE_T_MAX to guard against
           later undefined behaviour from "step = -step" reversal. */
        if (*step < -PY_SSIZE_T_MAX)
            *step = -PY_SSIZE_T_MAX;
    }
    if (r->start == Py_None) {
        *start = *step < 0 ? PY_SSIZE_T_MAX : 0;
    } else {
        if (!_PyEval_SliceIndex(r->start, start)) return -1;
    }
    if (r->stop == Py_None) {
        *stop = *step < 0 ? PY_SSIZE_T_MIN : PY_SSIZE_T_MAX;
    } else {
        if (!_PyEval_SliceIndex(r->stop, stop)) return -1;
    }
    return 0;
}

_PyEval_SliceIndex (in Python/ceval.c) is the bridge from “Python object” to “C integer”:

c
// Python/ceval.c
int
_PyEval_SliceIndex(PyObject *v, Py_ssize_t *pi)
{
    if (Py_IsNone(v)) {
        return 1;                 /* leaves *pi untouched */
    }
    return _PyEval_SliceIndexNotNone(v, pi);
}

int
_PyEval_SliceIndexNotNone(PyObject *v, Py_ssize_t *pi)
{
    PyThreadState *tstate = _PyThreadState_GET();
    Py_ssize_t x;
    if (PyLong_CheckExact(v) && _PyLong_IsCompact((PyLongObject *)v)) {
        *pi = _PyLong_CompactValue((PyLongObject *)v);
        return 1;
    }
    if (_PyIndex_Check(v)) {
        x = PyNumber_AsSsize_t(v, NULL);   /* calls __index__ */
        if (x == -1 && _PyErr_Occurred(tstate))
            return 0;
    } else {
        _PyErr_SetString(tstate, PyExc_TypeError,
                         "slice indices must be integers or have an __index__ method");
        return 0;
    }
    *pi = x;
    return 1;
}

So an omitted bound becomes the extreme sentinel (PY_SSIZE_T_MAX / PY_SSIZE_T_MIN), __index__ is honored for non-None values, and huge values are silently clamped to the Py_ssize_t range. None-handling for step defaults to 1.

The older PySlice_GetIndices (deprecated in 3.7) is a simpler variant that requires PyLong bounds and returns -1 on out-of-range. New code should use PySlice_Unpack + PySlice_AdjustIndices.

Adjusting indices: PySlice_AdjustIndices

After unpacking, the raw start/stop (which may be negative, or the extreme sentinels) must be clamped to [0, length] — or the reverse range for a negative step. PySlice_AdjustIndices does this and returns the slice length:

c
// Objects/sliceobject.c
Py_ssize_t
PySlice_AdjustIndices(Py_ssize_t length,
                      Py_ssize_t *start, Py_ssize_t *stop, Py_ssize_t step)
{
    assert(step != 0);
    assert(step >= -PY_SSIZE_T_MAX);

    if (*start < 0) {
        *start += length;
        if (*start < 0) {
            *start = (step < 0) ? -1 : 0;
        }
    } else if (*start >= length) {
        *start = (step < 0) ? length - 1 : length;
    }

    if (*stop < 0) {
        *stop += length;
        if (*stop < 0) {
            *stop = (step < 0) ? -1 : 0;
        }
    } else if (*stop >= length) {
        *stop = (step < 0) ? length - 1 : length;
    }

    if (step < 0) {
        if (*stop < *start) {
            return (*start - *stop - 1) / (-step) + 1;
        }
    } else {
        if (*start < *stop) {
            return (*stop - *start - 1) / step + 1;
        }
    }
    return 0;
}

The logic, in three steps:

  1. Negative start/stop are taken relative to length (i += length), then clamped to the appropriate boundary (0/length for forward steps, -1/length-1 for backward steps).
  2. Out-of-range positive values are clamped to the same boundaries.
  3. The returned count is the number of elements the slice covers, computed with integer division so a partial final step isn’t counted.

PySlice_GetIndicesEx is just the two steps glued together:

c
// Objects/sliceobject.c
int
PySlice_GetIndicesEx(PyObject *_r, Py_ssize_t length,
                     Py_ssize_t *start, Py_ssize_t *stop, Py_ssize_t *step,
                     Py_ssize_t *slicelength)
{
    if (PySlice_Unpack(_r, start, stop, step) < 0)
        return -1;
    *slicelength = PySlice_AdjustIndices(length, start, stop, *step);
    return 0;
}

slice.indices(len): the Python-facing version

slice.indices(length) returns a tuple of fully-adjusted (start, stop, step) integers. It’s implemented by _PySlice_GetLongIndices, which works in Python int objects (not Py_ssize_t) so huge values never overflow and out-of-bounds indices are clipped consistently:

c
// Objects/sliceobject.c
int
_PySlice_GetLongIndices(PySliceObject *self, PyObject *length,
                        PyObject **start_ptr, PyObject **stop_ptr,
                        PyObject **step_ptr)
{
    PyObject *start=NULL, *stop=NULL, *step=NULL;
    PyObject *upper=NULL, *lower=NULL;
    int step_is_negative, cmp_result;

    /* Convert step to an integer; raise for zero step. */
    if (self->step == Py_None) {
        step = _PyLong_GetOne();
        step_is_negative = 0;
    } else {
        step = evaluate_slice_index(self->step);   /* calls __index__ */
        ...
        step_is_negative = step_sign < 0;
    }

    /* Bounds depend on step direction. */
    if (step_is_negative) {
        lower = PyLong_FromLong(-1L);
        upper = PyNumber_Add(length, lower);      /* length - 1 */
    } else {
        lower = _PyLong_GetZero();               /* 0 */
        upper = Py_NewRef(length);               /* length */
    }

    /* Compute start: None -> upper/lower; else clamp to [lower, upper]. */
    if (self->start == Py_None) {
        start = Py_NewRef(step_is_negative ? upper : lower);
    } else {
        start = evaluate_slice_index(self->start);
        if (_PyLong_IsNegative((PyLongObject *)start)) {
            PyObject *tmp = PyNumber_Add(start, length);   /* start += length */
            Py_SETREF(start, tmp);
            cmp_result = PyObject_RichCompareBool(start, lower, Py_LT);
            if (cmp_result) Py_SETREF(start, Py_NewRef(lower));
        } else {
            cmp_result = PyObject_RichCompareBool(start, upper, Py_GT);
            if (cmp_result) Py_SETREF(start, Py_NewRef(upper));
        }
    }
    /* ... analogous block for stop ... */

    *start_ptr = start; *stop_ptr = stop; *step_ptr = step;
    ...
}

The returned start/stop/step are new references that the caller owns; slice.indices packs them into a tuple via Py_BuildValue("(NNN)", ...).

Worth internalizing: the Py_ssize_t path and the Python-int path implement the same semantics with different trade-offs. The C path is fast but clamps; the Python path is exact and overflow-free.

Equality, hashing, and repr

  • slice_repr produces slice(%R, %R, %R) from the three fields.
  • slice_richcompare short-circuits identity (v == w) to True for ==/<=/>= and False otherwise; otherwise it packs both triples into tuples and delegates to PyObject_RichCompare.
  • slice_hash mixes the three field hashes with the same xxhash-style mixer used for tuples.
  • slice_traverse visits all three fields for the GC.

Ellipsis

The same file defines the immortal Ellipsis singleton used for ...:

c
// Objects/sliceobject.c
PyObject _Py_EllipsisObject = _PyObject_HEAD_INIT(&PyEllipsis_Type);

ellipsis_new rejects any arguments and returns Py_Ellipsis, and ellipsis_dealloc re-immortalizes the object as a guard. Py_Ellipsis resolves to this struct (or to the immortal constant in the limited API).

Memory layout summary

text
PySliceObject
+---------------------------+
| ob_refcnt / ob_tid        |
+---------------------------+
| *ob_type  (&PySlice_Type) |
+---------------------------+
| *start  (never NULL)      |  -> Py_None or any PyObject
+---------------------------+
| *stop   (never NULL)      |
+---------------------------+
| *step   (never NULL)      |
+---------------------------+

Fixed size (sizeof(PySliceObject)), GC-tracked, with a 1-slot freelist. The three fields are the only payload, which is why a slice is cheap to create and why slice equality reduces to tuple equality of (start, stop, step).

Key takeaways

  • PySliceObject holds three non-NULL PyObject * fields (start, stop, step); omitted bounds are Py_None.
  • Construction goes through _PyBuildSlice_Consume2, which reuses a 1-slot freelist and tracks the object with the GC.
  • PySlice_Unpack converts the three fields to Py_ssize_t, treating None as extreme sentinels and honoring __index__ via _PyEval_SliceIndex.
  • PySlice_AdjustIndices clamps negative/out-of-range bounds and returns the number of elements covered.
  • _PySlice_GetLongIndices is the Python-int (overflow-free) variant used by slice.indices() and range slicing.
  • The Ellipsis singleton lives in the same translation unit.

Further reading

  • Objects/sliceobject.c – type object, construction, unpacking, adjustment, indices, hashing, GC traversal.
  • Include/cpython/sliceobject.h – PySliceObject layout and the limited-API prototypes.
  • Include/sliceobject.h – public C API (PySlice_New, PySlice_GetIndices, PySlice_Unpack, PySlice_AdjustIndices).
  • Python/ceval.c – _PyEval_SliceIndex / _PyEval_SliceIndexNotNone.
  • Python/bytecodes.c – BUILD_SLICE.
  • Lib/test/test_slice.py – reference implementation and edge cases.

PythonCpythonInternalsSlice