Zero-copy interop with C, Cython, images, GPUs and other libraries depends on understanding this structure.
At the C level an ndarray is a PyArrayObject whose key fields are: a data pointer to the first element, nd (number of dimensions), dimensions and strides arrays, a descr (the PyArray_Descr behind a.dtype, describing item size, kind, byte order and fields), a base reference, and flags (contiguity, WRITEABLE, OWNDATA, ALIGNED). An array either owns its buffer or points into memory owned by base, which it keeps alive by reference. Every view you create is just a new header with its own dimensions, strides and data offset.
Because the header is so simple, foreign memory can become an array without copying, as long as someone describes it. Several protocols do that:
- The buffer protocol (PEP 3118) is Python's C-level contract for exposing raw memory with a format and shape. ndarrays export it (
memoryview(a),bytes(a)), andnp.frombuffer(obj, dtype)ornp.asarray(obj)consume it frombytes,bytearray,array.array, mmap or image buffers. Arrays built over immutablebytesare read-only. __array_interface__(Python dict) and__array_struct__(C struct) are NumPy's own older protocols; the dict exposesdataas(address, readonly).- DLPack (
np.from_dlpack(x)anda.__dlpack__()) is the cross-framework standard for exchanging tensors, including with PyTorch, JAX and CuPy; it carries device information so libraries can refuse incompatible devices.
From C or Cython you reach the same fields with macros such as PyArray_DATA, PyArray_DIMS, PyArray_STRIDES. Code that assumes contiguity must check flags (or call PyArray_FROM_OTF with NPY_ARRAY_C_CONTIGUOUS) because a perfectly valid array might be a strided, negative-step or read-only view.
Example
import numpy as np
a = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32)
print(a.flags["OWNDATA"], a[:, 1].flags["OWNDATA"]) # True False
print(a.__array_interface__["data"][0] == a.ctypes.data) # True
mv = memoryview(a) # buffer protocol export
print(mv.format, mv.shape, mv.strides) # i (2, 3) (12, 4)
raw = bytearray(b"\x01\x00\x02\x00\x03\x00")
u = np.frombuffer(raw, dtype="<u2") # zero-copy view of the bytearray
raw[0] = 9
print(u) # [9 2 3] sees the change
ro = np.frombuffer(b"\x00" * 8, dtype=np.float64)
print(ro.flags.writeable) # False: bytes are immutable
b = np.from_dlpack(a) # DLPack exchange (here NumPy->NumPy)
print(np.shares_memory(a, b)) # TrueRemember
- An ndarray is a header (data pointer, dims, strides, descr, flags) over a buffer
- Views keep their memory owner alive through
base - Buffer protocol,
__array_interface__and DLPack enable zero-copy exchange - C code must honour strides and flags, not assume contiguity
Watch out for
- Freeing or resizing the underlying memory (e.g. a bytearray) while a NumPy view of it is still in use.