The Buffer Protocol
The buffer protocol (specified by PEP 3118, “Revising the Buffer Protocol,” authored by Travis Oliphant and Carl Banks) is CPython’s low-level mechanism for one object to expose its raw memory block to another object without copying it (PEP 3118). It is the reason a
bytes, abytearray, anarray.array, and a NumPyndarraycan all be handed to the same C function — or to the samememoryview— and have that function read or write their bytes in place. The protocol carries not just a pointer and a length but a full description of the memory: how many dimensions it has, how big each element is, what C type each element is, and how the elements are laid out (the strides). The single most important idea is zero-copy: passing a hundred-megabyte image between an image library and a numerical library should cost a few pointer assignments, not an O(n)memcpyof a hundred megabytes (PEP 3118 rationale).
This note covers the C-level structure (Py_buffer), the type slots that implement it (tp_as_buffer / bf_getbuffer / bf_releasebuffer), the Python-facing memoryview object, the format-string mini-language that describes element types, the lifetime/locking discipline that keeps exported memory valid, and the NumPy interoperability story. All Python-level behavior below was verified on a live CPython 3.14.x interpreter; version-sensitive C-API claims are cited to the 3.14 documentation and the PEPs.
Why It Exists
Python 2 had an older, weaker buffer protocol (PyBufferProcs with getreadbuffer/getwritebuffer). PEP 3118 lists its fatal inadequacies, every one of which the new protocol was designed to fix (PEP 3118):
- No “I’m done” notification. The old protocol gave a consumer a pointer but never told the exporter when the consumer finished. The exporter therefore could never safely free or resize its memory — a dangling pointer was always possible. The new protocol pairs every acquisition with a mandatory release, so the exporter can lock its memory for the lifetime of the export and unlock it afterward.
- No type information. The old buffer was just
void *+ length. A consumer receiving a megabyte of memory had no way to know whether it held 32-bit floats, 64-bit ints, or RGBA pixels. The new protocol carries a format string describing one element. - No shape. Array-like libraries (NumPy, the Python Imaging Library, wxPython, PyGTK) all model multi-dimensional data, but the old protocol had no standard way to communicate
(rows, cols). The new protocol carriesndimand ashapearray. - No discontiguous-memory support. A slice like
image[::2, ::2]is a view into every other pixel — its bytes are not contiguous. The old protocol couldn’t describe such a view, forcing a copy. The new protocol carriesstrides(byte gaps between elements) andsuboffsets(pointer indirection), so even a strided or pointer-of-pointers layout can be shared without copying.
The motivating use case is interoperation between numerical libraries. Before PEP 3118, moving an array from one C library to another meant serializing to bytes and re-parsing — an O(n) copy and a loss of shape/type metadata. With the buffer protocol, NumPy can hand a 2-D float array straight to a C routine that reads it in place. The protocol became “fundamental to Python’s scientific computing ecosystem” (PEP 3118).
Mental Model
Think of the protocol as a contract between an exporter (the object that owns the memory) and a consumer (the object or C code that wants to read/write it). The consumer asks for a buffer, optionally stating requirements via flags (“I need it writable,” “I can only handle contiguous memory,” “fill in the format string”). The exporter either satisfies the request — filling a Py_buffer description struct and bumping its own reference count — or refuses with a BufferError. When the consumer is done, it must release the buffer, which decrements the exporter’s refcount and unlocks the memory.
flowchart LR subgraph Exporter["Exporter (owns memory)"] MEM["raw bytes<br/>e.g. bytearray, ndarray"] SLOT["tp_as_buffer:<br/>bf_getbuffer / bf_releasebuffer"] end subgraph Consumer["Consumer (wants memory)"] VIEW["Py_buffer view struct<br/>buf, len, format,<br/>ndim, shape, strides"] end Consumer -->|"PyObject_GetBuffer(exporter, &view, flags)"| SLOT SLOT -->|"fill view, incref self,<br/>lock against resize"| VIEW VIEW -.->|"view.buf points into"| MEM Consumer -->|"PyBuffer_Release(&view)"| SLOT SLOT -->|"decref self, unlock"| MEM
Diagram: the export handshake. The consumer requests a buffer through the exporter’s bf_getbuffer slot, receives a Py_buffer whose buf field points directly into the exporter’s memory (no copy), and the exporter increments its own refcount and locks against resizing for as long as the view lives. PyBuffer_Release reverses both. The insight: the Py_buffer is metadata describing borrowed memory — the bytes are never duplicated, only described.
The Py_buffer Struct, Field by Field
The heart of the protocol is the Py_buffer C struct. A consumer allocates one (usually on its C stack) and passes its address to PyObject_GetBuffer; the exporter fills every field. Here is each field, with its exact type and meaning from the 3.14 C-API reference (Buffer Protocol):
void *buf— A pointer to the start of the logical structure. It need not be the start of the physical block: with negative strides it may point to the end of the memory (the logical first element). For contiguous arrays it points to the beginning. This is the pointer the consumer dereferences (with stride arithmetic) to reach each element.PyObject *obj— A new reference to the exporting object. The consumer owns this reference;PyBuffer_Releasedecrements it and sets the field toNULL. This is what keeps the exporter alive while the view exists, and what the release machinery uses to find the exporter’sbf_releasebufferslot.Py_ssize_t len—product(shape) * itemsize. For contiguous arrays this is the length of the underlying memory block in bytes. For non-contiguous arrays it is the length the data would occupy if copied to a contiguous layout — not the number of bytes physically spanned. (Py_ssize_tis CPython’s signed pointer-sized integer, the type used for all sizes and indices.)Py_ssize_t itemsize— The size in bytes of one element. It equalsstruct.calcsize(format). For adoublearray this is 8; for raw bytes it is 1.int readonly— 1 if the buffer must not be written, 0 if it is writable. Controlled by thePyBUF_WRITABLErequest flag: if the consumer set that flag the exporter must provide writable memory or fail.int ndim— The number of dimensions. Ifndim == 0,bufpoints to a single scalar item andshape/strides/suboffsetsare allNULL. The maximum isPyBUF_MAX_NDIM, currently 64 (C-API buffer).char *format— A NUL-terminated string in struct-module syntax describing one element’s type (see the format mini-language below). IfNULL,'B'(unsigned byte) is assumed. Controlled by thePyBUF_FORMATflag.Py_ssize_t *shape— An array of lengthndimgiving the size in elements along each dimension. The invariantshape[0] * ... * shape[ndim-1] * itemsize == lenmust hold.Py_ssize_t *strides— An array of lengthndimgiving, per dimension, the number of bytes to skip to advance one element along that axis. Stride values may be any integer, including negative (reverse iteration) or zero (broadcasting). This is what makes a sliced view zero-copy: a step-2 slice has a stride of2 * itemsizeinstead of pointing at a copied compacted block.Py_ssize_t *suboffsets— An array of lengthndim, orNULL. Ifsuboffsets[n] >= 0, the values along dimensionnare pointers that must be dereferenced, and the suboffset says how many bytes to add after dereferencing. A negative entry means no dereference. This supports the Python Imaging Library’s “array of row pointers” layout, where rows are scattered in memory.void *internal— Reserved for the exporter’s private bookkeeping. The consumer must not read or alter it.
To reach element [i0, i1, ..., in-1] of a strided (but non-indirect) buffer, the consumer computes (C-API buffer):
char *ptr = (char *)view.buf
+ indices[0] * view.strides[0]
+ ...
+ indices[n-1] * view.strides[n-1];
item = *((ItemType *)ptr);When suboffsets are present, each step may additionally dereference a pointer and add the suboffset — the PIL-style access pattern from the docs:
char *pointer = (char *)buf;
for (int i = 0; i < ndim; i++) {
pointer += strides[i] * indices[i];
if (suboffsets[i] >= 0)
pointer = *((char **)pointer) + suboffsets[i];
}Each line: start at buf; for every dimension, advance by stride × index; if that dimension is indirect, follow the pointer stored there and add the suboffset. This single loop unifies contiguous, strided, and pointer-of-pointers layouts under one access model.
Contiguity: C, Fortran, and Strided
Three layouts matter. The Python glossary defines them precisely (glossary: contiguous):
A buffer is considered contiguous exactly if it is either C-contiguous or Fortran contiguous. Zero-dimensional buffers are C and Fortran contiguous. In one-dimensional arrays, the items must be laid out in memory next to each other, in order of increasing indexes starting from zero. In multidimensional C-contiguous arrays, the last index varies the fastest when visiting items in order of memory address. However, in Fortran contiguous arrays, the first index varies the fastest.
In plain terms: a C-contiguous 2-D array stores its data row-by-row (the rightmost index moves fastest), the layout C and Python default to. A Fortran-contiguous array stores column-by-column (the leftmost index moves fastest), the convention of Fortran, MATLAB, and numpy.asfortranarray. A strided (non-contiguous) view — produced by, say, a[::2] — is neither: there are gaps between consecutive logical elements. Verified live, a 2×3 C-contiguous float64 array reports strides == (24, 8) (skip 24 bytes for the next row, 8 for the next column); its Fortran transpose reports strides == (8, 16) and f_contiguous == True; and a bytearray[::2] view reports c_contiguous == False, strides == (2,).
A consumer that can only handle contiguous memory requests it via flags; if it asks for PyBUF_C_CONTIGUOUS and the exporter’s data is strided, the export fails rather than silently copying — the consumer must then decide to copy explicitly.
Request Flags
The flags argument to PyObject_GetBuffer is a bitmask telling the exporter what the consumer needs and can handle. The structural flags form a ladder of increasing capability (C-API buffer):
| Flag | shape | strides | suboffsets | Meaning |
|---|---|---|---|---|
PyBUF_SIMPLE | NULL | NULL | NULL | Just a flat byte block; consumer can’t handle shape |
PyBUF_ND | yes | NULL | NULL | C-contiguous N-D array, no strides |
PyBUF_STRIDES | yes | yes | NULL | Strided array (implies shape) |
PyBUF_INDIRECT | yes | yes | if needed | Full generality incl. pointer indirection |
PyBUF_WRITABLE (sets readonly handling — exporter must give writable memory or fail) and PyBUF_FORMAT (exporter must fill format) are OR-ed onto the structural flags. PyBUF_FORMAT must be combined with something other than PyBUF_SIMPLE, since PyBUF_SIMPLE already implies the format is 'B'. Convenience combinations exist: PyBUF_CONTIG (writable, C-contiguous, no format), PyBUF_CONTIG_RO (read-only variant), PyBUF_STRIDED, PyBUF_RECORDS (strided + format), PyBUF_FULL (strides + suboffsets if needed + format + writable), and their _RO read-only siblings. The mental rule: the consumer requests the least it can tolerate; the exporter provides at least that or raises BufferError.
The C-Level Slots: tp_as_buffer, bf_getbuffer, bf_releasebuffer
A type advertises buffer support through the tp_as_buffer slot of its PyTypeObject (see Type Slots and the Protocol Tables for how the tp_as_* sub-tables work). That slot points at a PyBufferProcs struct with exactly two function pointers (C-API type objects):
typedef struct {
int (*bf_getbuffer)(PyObject *exporter, Py_buffer *view, int flags);
void (*bf_releasebuffer)(PyObject *exporter, Py_buffer *view);
} PyBufferProcs;bf_getbuffer (type getbufferproc) is the export handshake. Its contract, from the documentation of the public wrapper PyObject_GetBuffer (C-API buffer):
Send a request to exporter to fill in view as specified by flags. If the exporter cannot provide a buffer of the exact type, it MUST raise
BufferError, setview->objtoNULLand return-1. On success, fill in view, setview->objto a new reference to exporter and return 0.
So a correct bf_getbuffer must: (1) check the requested flags against what it can provide and fail with BufferError if it cannot; (2) fill every relevant Py_buffer field; (3) Py_INCREF the exporter and store it in view->obj (this is the new reference that keeps the exporter alive); (4) return 0. The PyBuffer_FillInfo helper does most of this for the common one-dimensional contiguous case.
bf_releasebuffer (type releasebufferproc) is called — through PyBuffer_Release — when the consumer is finished. It is the exporter’s chance to undo any per-export bookkeeping, such as decrementing an “export count” that locks the object against resizing. It returns void and may be NULL if the exporter needs no cleanup beyond the automatic Py_DECREF of view->obj (which PyBuffer_Release always does). The documentation stresses the pairing: “Successful calls to PyObject_GetBuffer() must be paired with calls to PyBuffer_Release(), similar to malloc() and free().”
Several core built-ins expose buffers through these slots: bytes and bytearray implement bf_getbuffer (with bytearray additionally tracking exports to forbid resizing — see CPython Bytes and Bytearray Internals), as do array.array and memoryview itself.
Lifetime and Locking — Why You Can’t Resize an Exported bytearray
The release-pairing contract has a visible Python-level consequence. While a buffer is exported, the exporter’s memory must not move. A bytearray that grew (and thus reallocated its backing store via realloc) while a consumer held a pointer into the old store would hand that consumer a dangling pointer. CPython prevents this by maintaining an export count on bytearray: every bf_getbuffer increments it, every bf_releasebuffer decrements it, and any operation that would resize the buffer checks the count first.
Verified live on 3.14.x — opening a memoryview exports the buffer, and a subsequent resize raises:
>>> ba = bytearray(b'test')
>>> m = memoryview(ba) # exports a buffer; export count -> 1
>>> ba.append(1)
BufferError: Existing exports of data: object cannot be re-sized
>>> ba.clear()
BufferError: Existing exports of data: object cannot be re-sized
>>> m.release() # decrements export count -> 0
>>> ba.append(1) # now permitted
>>> ba
bytearray(b'test\x01')The exact message — "Existing exports of data: object cannot be re-sized" — is the symptom you will see in real code that forgets to release() a view before resizing. This is also why memoryview is a context manager: with memoryview(ba) as m: guarantees the export count returns to zero on block exit even if an exception fires. Reading and in-place writing through the view are always fine; only operations that would reallocate (append, extend, clear, slice-assignment that changes length) are blocked. Immutable bytes has no such issue because it never resizes.
The Python-Facing Object: memoryview
memoryview is the protocol made first-class in Python. memoryview(obj) acquires a buffer from any object that exports one — bytes, bytearray, array.array, NumPy arrays, and any class implementing __buffer__ — and exposes it without copying (stdtypes: memoryview). Its attributes mirror the Py_buffer fields: obj, nbytes (= product(shape) * itemsize), readonly, format, itemsize, ndim, shape, strides, suboffsets, plus the booleans c_contiguous, f_contiguous, and contiguous.
Its methods: tobytes() (copy the data out to a bytes — this does copy), hex(), tolist() (unpack each element per format into a Python list), toreadonly() (a read-only view of the same memory), release() (release the buffer early), cast(format[, shape]), and __eq__. It is also a context manager (__enter__/__exit__ call release on exit).
Slicing is zero-copy. A slice of a memoryview returns a new memoryview over a subset of the same buffer — no bytes are copied, and writes through the slice mutate the original. Verified live:
>>> ba = bytearray(b'abcdef')
>>> m = memoryview(ba)
>>> s = m[2:5] # view of bytes 2..4, NOT a copy
>>> bytes(s)
b'cde'
>>> s[0] = ord('Z') # write through the slice
>>> ba # underlying bytearray mutated in place
bytearray(b'abZdef')This is the property that makes memoryview valuable for performance: parsing a network packet by repeatedly slicing a memoryview allocates no intermediate buffers, whereas slicing bytes copies each time.
.cast() reinterprets the bytes under a different element type or shape, again without copying (stdtypes: memoryview). It has two hard restrictions: the source must be C-contiguous, and casts are allowed only between formats with the same itemsize, or to/from 'B' (raw bytes). Verified live:
>>> m = memoryview(b'\x00\x01\x02\x03\x04\x05\x06\x07')
>>> m.cast('I').tolist() # reinterpret 8 bytes as 2 uint32
[50462976, 117835012]
>>> m.cast('B', shape=[2, 4]).tolist() # same bytes, 2x4 layout
[[0, 1, 2, 3], [4, 5, 6, 7]]
>>> memoryview(bytearray(range(10)))[::2].cast('B')
TypeError: memoryview: casts are restricted to C-contiguous viewsThe last line shows the restriction biting: a strided ([::2]) view is not C-contiguous, so cast refuses with that exact TypeError. nbytes versus len() is another live gotcha: for an array.array('i', [1,2,3]), m.nbytes == 12 (three 4-byte ints) but len(m) == 3 (the element count), since len reports the first dimension’s size, not the byte count.
The Format-String Mini-Language
The format field describes one element using the struct module’s syntax, which PEP 3118 extends. The base codes are the struct standard codes (struct: format characters): b/B (signed/unsigned char, 1 byte), ? (_Bool, 1), h/H (short, 2), i/I (int, 4), l/L (long, 4 standard), q/Q (long long, 8), n/N (ssize_t/size_t), e/f/d (16/32/64-bit float), s/p (char arrays), P (void*), c (1-byte). A leading byte-order/size/alignment character may prefix the string (struct docs): @ = native byte order, native size, native alignment (the default); = = native byte order, standard size, no alignment; < = little-endian; > = big-endian; ! = network order (always big-endian).
PEP 3118 adds codes to this base for the buffer protocol’s richer needs (PEP 3118 additions): 't' (a bit), 'g' (long double), 'c'/'u'/'w' (UCS-1/2/4 text), 'O' (pointer to a Python object), 'Z' as a complex prefix (Zf = complex float, Zd = complex double), '&' (pointer to another type), 'T{...}' (a nested struct with a field layout inside the braces), '(k1,k2,...)' (a multi-dimensional sub-array), ':name:' (a field name annotation), 'X{}' (a function pointer), and '^' as an “unaligned native” byte-order char.
Resolved (2026-06-01)
Enumerated directly on live CPython 3.14.5 and cross-checked against
Modules/_struct.cat thev3.14.5tag. Two facts settle the superset/subset question: (1) Thestructmodule’snative_tableacceptsx b B c s p h H i I l L n N q Q ? e f d F D P— noteF/D(complex float/double) are the codesstructactually implements for complex; the PEP 3118'Z'-prefix forms are notstruct-typeable:struct.calcsize('Zf')raisesstruct.error, whilestruct.calcsize('F')returns 8 andstruct.unpack('F', struct.pack('F', 1+2j))round-trips. (2)memoryview.cast()accepts only native single-character formats (optionally@-prefixed):cast('Zf')andcast('T{i:x:}')both raiseValueError: memoryview: destination format must be a native single character format.... So the buffer format mini-language is a strict superset of whatstruct/memoryview.castaccept at runtime — composite forms (Zf,T{...},(k,...)) are produced by exporters such as NumPy and consumed by readers ofPy_buffer.format, not constructible viacastorstruct.pack.
The Python-Level Buffer Protocol (PEP 688)
Until Python 3.12 the buffer protocol was reachable only from C. PEP 688 (“Making the Buffer Protocol Accessible in Python,” added in 3.12) exposed it to Python code (PEP 688). A pure-Python class can now implement two dunder methods (PEP 688 Python-level protocol):
def __buffer__(self, flags: int, /) -> memoryview: ...
def __release_buffer__(self, buffer: memoryview, /) -> None: ...__buffer__ “is called to create a buffer from a Python object, for example by the memoryview() constructor” and must return a memoryview. __release_buffer__ “should be called when a caller no longer needs the buffer” and is “an optional part of the buffer protocol” (PEP 688). PEP 688 also adds collections.abc.Buffer — an abstract base class requiring __buffer__, intended for type annotations and isinstance checks — and inspect.BufferFlags, an enum.IntFlag mirroring the C request flags. Verified live on 3.14.x:
>>> import collections.abc, inspect
>>> isinstance(b'x', collections.abc.Buffer)
True
>>> inspect.BufferFlags.SIMPLE, inspect.BufferFlags.WRITABLE, inspect.BufferFlags.FORMAT
(<BufferFlags.SIMPLE: 0>, <BufferFlags.WRITABLE: 1>, <BufferFlags.FORMAT: 4>)
>>> class MyBuf:
... def __init__(self, data): self.data = bytearray(data)
... def __buffer__(self, flags): return memoryview(self.data)
... def __release_buffer__(self, view): view.release()
>>> bytes(memoryview(MyBuf(b'hello')))
b'hello'The flags integer the interpreter passes to __buffer__ is exactly the C request bitmask, decodable with inspect.BufferFlags, so a Python implementation can honor WRITABLE/FORMAT requests just as a C exporter would.
NumPy Interop — Two Related Protocols
NumPy is both the original motivation for PEP 3118 and its biggest beneficiary. There are two distinct zero-copy mechanisms, and they are easy to conflate.
1. The buffer protocol itself. A NumPy ndarray is a buffer exporter, and np.frombuffer is a buffer consumer. Verified live on CPython 3.14.x with NumPy 2.3.5:
>>> import numpy as np
>>> ba = bytearray(b'\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00')
>>> arr = np.frombuffer(ba, dtype=np.int32) # zero-copy view onto ba
>>> arr
array([1, 2, 3], dtype=int32)
>>> ba[0:4] = (99).to_bytes(4, 'little') # mutate the bytearray
>>> arr
array([99, 2, 3], dtype=int32) # numpy view sees the changeConversely, memoryview(ndarray) makes NumPy the exporter — the memoryview reports NumPy’s format, itemsize, shape, and strides directly. A 2×3 float64 C-contiguous array yields format='d', shape=(2,3), strides=(24,8); calling np.asfortranarray and re-wrapping yields f_contiguous=True, strides=(8,16). Writes through the memoryview land in the NumPy array (memoryview(np.zeros(4, np.uint8))[0] = 200 sets arr[0]), demonstrating bidirectional zero-copy sharing.
2. The array interface (__array_interface__). This is a separate, older NumPy-specific protocol — a Python dictionary (or a C-struct variant) that describes the same memory through keys data (a (pointer, readonly) tuple), shape, typestr, strides, descr, and version. Verified live:
>>> np.arange(4, dtype=np.int16).__array_interface__
{'data': (94745275473824, False), 'strides': None,
'descr': [('', '<i2')], 'typestr': '<i2', 'shape': (4,), 'version': 3}The array interface predates PEP 3118 and lives entirely in NumPy/Python space (it exposes a raw integer pointer rather than going through tp_as_buffer). For modern interop within the scientific stack the buffer protocol is preferred, but __array_interface__ remains supported and is what some libraries still probe.
Uncertain (reviewed 2026-06-01)
Verify: that
__array_interface__is still the recommended cross-library mechanism vs. the newer__dlpack__/ DLPack protocol (and the array-API standard) that much of the ecosystem has moved toward. Reason: this is a third-party (NumPy) convention, not a CPython-version fact, and the current numpy.org interop-protocols docs were not fetched this round. To resolve: consult the current numpy.org “interoperability” page and the array-API standard for the present recommendation; the buffer-protocol mechanics this note teaches are unaffected either way.#uncertain
Failure Modes and Common Misunderstandings
- Forgetting to release a view, then resizing. The classic bug: hold a
memoryviewon abytearray, try toappend/extend/clearit, and getBufferError: Existing exports of data: object cannot be re-sized. Fix: scope the view withwith, or call.release()before resizing. - Assuming
tobytes()is free. Slicing andcastare zero-copy, buttobytes(),tolist(), andhex()materialize a copy. Reaching fortobytes()in a hot loop reintroduces the O(n) cost the protocol was meant to avoid. caston a non-contiguous view.castrequires C-contiguity and matching itemsize (or'B'); a strided slice raisesTypeError: memoryview: casts are restricted to C-contiguous views. To cast strided data you must first copy it contiguous.len(m)vsm.nbytes.lenis the first-dimension element count,nbytesis the byte total. Confusing them on a multi-byte-element array (array('i', ...)→len 3,nbytes 12) is a frequent off-by-N.- Refcount leaks in C. Every
PyObject_GetBufferthat returns 0 must be balanced byPyBuffer_Release, or the exporter’s refcount (and its memory lock) leaks. This mirrors the broader owned-reference discipline of Reference Counting in C Extensions.
See Also
- The CPython C API — the API surface this protocol is part of.
- Writing C Extension Modules — where you implement
bf_getbuffer/bf_releasebufferin a custom type. - Type Slots and the Protocol Tables — how
tp_as_bufferfits among thetp_as_*sub-tables. - CPython Bytes and Bytearray Internals — the export-count locking and why
bytearraycan’t resize while exported. - Cython — typed memoryviews (
int[:, :]) compile directly to buffer-protocol acquisitions for fast, bounds-checked array access. - Python Internals MOC — §15, “The C API and Extending CPython.”