Lesson 12

Buffers and Zero-Copy

The protocol that lets C code read and write the raw memory behind a Python object — with no copy, no boxing, and a pin that keeps it alive while the interpreter is released.

Learning objective

You can consume a buffer from any bytes-like or array-like object safely, request exactly the memory layout you can handle, interpret shape and strides, export your own buffer, and explain why the buffer protocol is the correct way to feed a detached compute loop.

The problem it solves

Lesson 01 established the cost of the object model: a list of a million floats is a million heap objects. A C extension that wants to process bulk numeric or binary data cannot afford to unbox them one at a time — the unboxing is the cost.

The buffer protocol (PEP 3118) is CPython's answer. An object that owns a flat block of memory — bytes, bytearray, array.array, mmap, a NumPy array, your own type — can expose that block directly. A consumer gets a pointer, a length, and a description of the layout, and then works in plain C. No copy, and no Python objects involved in the inner loop.

It is also the only mechanism that makes "release the interpreter and compute" (Lesson 10) sound: acquiring a buffer pins the memory, so the pointer stays valid while you are detached.

Py_buffer

typedef struct {
    void       *buf;         /* pointer to the start of the logical data */
    PyObject   *obj;         /* the exporter -- a STRONG reference you own */
    Py_ssize_t  len;         /* total bytes = product(shape) * itemsize    */
    Py_ssize_t  itemsize;    /* bytes per element                          */
    int         readonly;    /* 1 if you must not write                    */
    int         ndim;        /* number of dimensions; 0 means a scalar     */
    char       *format;      /* struct-module syntax, e.g. "d", "i", "B"   */
    Py_ssize_t *shape;       /* ndim entries, or NULL                      */
    Py_ssize_t *strides;     /* ndim entries in BYTES, or NULL             */
    Py_ssize_t *suboffsets;  /* ndim entries, or NULL (pointer arrays)     */
    void       *internal;    /* exporter's private data -- do not touch    */
} Py_buffer;

Which of those fields are filled depends on what you asked for. The exporter fills only what your request flags permit, which is how a simple consumer avoids having to handle strided multi-dimensional layouts.

Consuming a buffer

Py_buffer view;
if (PyObject_GetBuffer(obj, &view, PyBUF_C_CONTIGUOUS | PyBUF_FORMAT) < 0) {
    return NULL;            /* BufferError already set: obj cannot comply */
}

... use view.buf, view.len, view.itemsize ...

PyBuffer_Release(&view);    /* mandatory, on every path */

Three properties of this pairing:

Request flags: ask for the least you can handle

The flags are a contract in both directions. You state what you can cope with; the exporter either complies or refuses.

FlagYou are saying
PyBUF_SIMPLE (value 0)"Give me a flat, C-contiguous, read-only block. No shape, no strides, no format." The most restrictive request and the easiest to handle.
PyBUF_WRITABLE"I intend to write." A read-only exporter refuses.
PyBUF_FORMAT"Tell me the element type." Without it, format is NULL and you must assume unsigned bytes.
PyBUF_ND"I can handle multiple dimensions, C-contiguous." Fills shape.
PyBUF_STRIDES"I can handle arbitrary strides." Fills shape and strides. Accepts non-contiguous data such as a transposed or sliced array.
PyBUF_INDIRECT"I can follow pointer indirection." Fills suboffsets too. You almost certainly cannot; do not ask.
PyBUF_C_CONTIGUOUS / PyBUF_F_CONTIGUOUS / PyBUF_ANY_CONTIGUOUS"Only accept this contiguity." The exporter refuses rather than silently giving you a strided view.
PyBUF_CONTIG, PyBUF_CONTIG_ROShorthand: shape + C-contiguous, writable / read-only, no format.
PyBUF_STRIDED, PyBUF_STRIDED_ROShorthand: shape + strides, no suboffsets, no format.
PyBUF_RECORDS, PyBUF_RECORDS_ROShorthand: shape + strides + format.
PyBUF_FULL, PyBUF_FULL_ROEverything, including suboffsets.
The practical default

For a one-dimensional numeric kernel, request PyBUF_C_CONTIGUOUS | PyBUF_FORMAT and then verify ndim, itemsize and format yourself. You get a clean BufferError for a transposed NumPy view rather than silently computing nonsense, and the caller can fix it with np.ascontiguousarray. Refusing loudly beats guessing.

Validating what you got

if (view.ndim != 1) {
    PyBuffer_Release(&view);
    PyErr_SetString(PyExc_ValueError, "expected a 1-D buffer");
    return NULL;
}
if (view.format == NULL || strcmp(view.format, "d") != 0) {
    PyBuffer_Release(&view);
    PyErr_SetString(PyExc_TypeError, "expected a buffer of C doubles ('d')");
    return NULL;
}
Py_ssize_t n = view.shape[0];        /* or view.len / view.itemsize */

format uses the struct module's syntax: "B" unsigned char, "b" signed char, "h"/"i"/"l"/"q" integers, "f"/"d" floats, with "<"/">" prefixes for byte order. Do not skip this check: a caller passing an int32 array where you expect float64 will otherwise produce garbage with no error.

The shortcut through argument parsing

Lesson 06's format units s*, y* and w* acquire a buffer for you (w* demanding a writable one). They are convenient, but they request a permissive layout, so you still validate — and you still must release:

Py_buffer data;
if (!PyArg_ParseTuple(args, "y*", &data)) return NULL;
...
PyBuffer_Release(&data);

Strides, when you do accept them

With PyBUF_STRIDES, element (i, j) lives at (char *)view.buf + i*strides[0] + j*strides[1]. Strides are in bytes, not elements, and may be negative (a reversed slice). PyBuffer_GetPointer(&view, indices) does the arithmetic for you, and PyBuffer_IsContiguous(&view, 'C') lets you take a fast path when the strides happen to be contiguous anyway.

Exporting a buffer from your own type

Two slots, in a PyBufferProcs struct hung off tp_as_buffer (or Py_bf_getbuffer / Py_bf_releasebuffer for a heap type):

static int
Block_getbuffer(PyObject *op, Py_buffer *view, int flags)
{
    BlockObject *self = (BlockObject *)op;

    if ((flags & PyBUF_WRITABLE) && self->readonly) {
        PyErr_SetString(PyExc_BufferError, "block is read-only");
        view->obj = NULL;
        return -1;
    }

    /* PyBuffer_FillInfo handles the common 1-D byte-oriented case: it fills
       every field, honours the flags, and increfs the exporter for you. */
    return PyBuffer_FillInfo(view, op, self->data, self->nbytes,
                             self->readonly, flags);
}

static void
Block_releasebuffer(PyObject *op, Py_buffer *view)
{
    BlockObject *self = (BlockObject *)op;
    self->exports--;        /* only needed if you must track outstanding views */
}

static PyType_Slot Block_slots[] = {
    {Py_bf_getbuffer,     Block_getbuffer},
    {Py_bf_releasebuffer, Block_releasebuffer},
    ...
};

PyBuffer_FillInfo is the right tool unless you are exporting a multi-dimensional layout.

Obligations of an exporter:

Buffers and the detached region

This is where the lesson pays off. A pinned buffer is one of the very few things you may legitimately touch while detached:

const double *p = (const double *)view.buf;
Py_ssize_t n = view.shape[0];

Py_BEGIN_ALLOW_THREADS
for (Py_ssize_t i = 0; i < n; i++) out[i] = p[i] * k;
Py_END_ALLOW_THREADS
Pinned is not the same as immutable

The buffer guarantees the address stays valid, not that the contents stay unchanged. Another thread holding a writable view of the same bytearray can mutate it underneath you while you are detached, and on a free-threaded build it does not even need to be another Python thread doing it cooperatively. If your algorithm requires a stable snapshot, either request a read-only view of an immutable object such as bytes, or copy.

Where this sits relative to NumPy

NumPy arrays export the buffer protocol, so everything above works on them and is the right choice when you want to accept "any array-like" without depending on NumPy at build time. If instead you are writing a NumPy-specific extension, NumPy has its own richer C API (PyArray_*) with dtypes, broadcasting and ufuncs that the buffer protocol does not model.

For zero-copy exchange with GPU frameworks and other array libraries, the current lingua franca is DLPack (exposed in Python as __dlpack__), which handles device memory that the buffer protocol cannot describe. Know it exists; the buffer protocol remains the right answer for CPU memory.

Hands-on

memoryview is the buffer protocol exposed to Python, so you can explore every field from the REPL:

import array

a = array.array("d", [1.0, 2.0, 3.0, 4.0])
m = memoryview(a)
print(m.format, m.itemsize, m.ndim, m.shape, m.strides, m.readonly, m.c_contiguous)

# Zero-copy mutation through a view of a mutable object:
ba = bytearray(b"hello")
mv = memoryview(ba)
mv[0] = ord("H")
print(ba)

# The pin is real: resizing while exported is refused.
try:
    ba.extend(b"!!")
except BufferError as e:
    print("BufferError:", e)
del mv
ba.extend(b"!!")
print(ba)

# Strides and non-contiguity, the case your C code must reject or handle:
try:
    import numpy as np
    arr = np.arange(12, dtype=np.float64).reshape(3, 4)
    print(memoryview(arr).strides, memoryview(arr).c_contiguous)
    print(memoryview(arr.T).strides, memoryview(arr.T).c_contiguous)
except ImportError:
    pass

Then read code/bufsum/ in this course, which acquires a buffer, validates it, releases the interpreter, and sums it. Modify it to reject non-contiguous input with a clear message, and test it with arr.T.

Further reading