Every tensor in PyTorch, every DataFrame in pandas, every embedding matrix that flows through a transformer — almost all of it is, at the bottom, an ndarray with a small header and a flat buffer of bytes. NumPy is twenty years old, written in a mix of Python and C, and it still sets the vocabulary the whole ecosystem speaks: dtype, axis, broadcasting, ufunc. If you have ever wondered how np.mean on ten million floats finishes in a few milliseconds, the answer is a layered design where a thin Python shell hands work to a C engine that walks memory with strides.
This post is a source tour of the numpy/numpy repository on the main branch. We start at the public namespace, descend through the Python API surface, cross the bridge into the C engine under numpy/_core/src/, and finish with the file layout changes that NumPy 2.x introduced. Every file and line number cited below was verified against the actual tree, so you can click through and read the code yourself.
The overview map: NumPy's Python namespace (left) funnels through the API and dispatch layers (middle) into the two C engine halves — multiarray for arrays and indexing, umath for vectorized functions (right).
Reading the overview from left to right:
- The package entry point is
numpy/__init__.py, which builds thenpnamespace by importing from the private core package — see numpy/init.py, L119 wherefrom . import _corehappens. - The C extension is exposed to Python through a small wrapper module: multiarray.py, L12 executes
from ._multiarray_umath import *, re-exportingarray,asarray,zeros,empty, and friends from a single merged binary. - Factory helpers like zeros_like, L98 live in
numpy/_core/numeric.py, which imports the C names rather than reimplementing them. - The big function surface — sum, L2462 and mean, L3979 — sits in
numpy/_core/fromnumeric.pyas functions that operate on arrays, as opposed to methods that live on the array itself. - Generators like linspace, L28 are the first stop for anyone teaching NumPy, and they are pure Python glue over the same core.
- Everything public is wrapped by the dispatch decorator array_function_dispatch, L119, which gives downstream libraries a hook to intercept NumPy calls.
- On the C side, the universal constructor is PyArray_FromAny, L1466 — every
np.array(...)you have ever written funnels through it. - Indexing and slicing go through array_subscript, L1480, the C-level implementation of
arr[...]. - Elementwise math dispatches through ufunc_generic_vectorcall, L5222, the vectorcall entry of the
ufunctype.
The Python API surface
NumPy’s Python layer is deliberately boring: it converts arguments, picks a reasonable policy, and delegates. np.mean is a good example. The public function at mean, L3979 validates arguments, then at mean body, L4101 calls into _methods._mean. That helper in _mean, L115 computes the reduction with umr_sum (a ufunc) divided by the count — and for small arrays it takes a fast path that skips most of the machinery. The same pattern holds for _sum, L47 and the _var/_std family: the Python layer is a shell around ufunc reductions.
Reductions themselves funnel through _wrapreduction, L43, a single helper that turns axis, dtype, and out arguments into a call on the ufunc’s .reduce() method. This is why np.sum and ndarray.sum behave identically — one is a thin wrapper around the other.
The reshaping utilities live in sibling modules: stack, L377 and vstack, L219 in shape_base.py, plus the Einstein-summation parser einsum, L1243, which compiles its little DSL down to a call into c_einsum in the C extension. Even np.memmap — a disk-backed array — is just a subclass declared at memmap, L24 that overrides allocation rather than the math.
The numpy.lib layer adds tools that need more Python orchestration. The view builder sliding_window_view, L180 never copies data; it synthesizes a shape plus strides and lets the C engine read the buffer differently. Its sibling as_strided, L39 is the raw, sharp-edged version of the same idea — powerful and famously foot-gunny. And for the rare arrays with invalid values, the whole numpy.ma subpackage is anchored by the class MaskedArray, L2779, a subclass that layers a boolean mask over a normal ndarray.
The C engine underneath
The real work happens in two directories: numpy/_core/src/multiarray/ for array objects, dtypes, and indexing, and numpy/_core/src/umath/ for vectorized functions.
Construction. np.asarray resolves to array_asarray, L1815 in the extension module, which calls the grand-coercion funnel PyArray_FromAny, L1466. That function decides the dtype, walks nested Python sequences, and either copies or views the input. DType discovery is delegated to the coercion machinery in array_coercion.c — scalar packing is handled by PyArray_Pack, L481, and candidate descriptors are stacked up through npy_new_coercion_cache, L626 before one winner is promoted.
Buffers. Every data buffer is obtained through the allocator at npy_alloc_cache, L180, which caches freed blocks so that churn-heavy code — think temporary results in a loop — avoids hammering the OS allocator. Descriptors (PyArray_Descr) are managed in descriptor.c; when a coercion produces both a DType and its parameterized descriptor, you can see it at PyArray_ExtractDTypeAndDescriptor, L1528.
Movement of data. Copies and casts between layouts are built by PyArray_GetDTypeTransferFunction, L3082, which returns optimized strided loops for a given pair of dtypes and memory layouts. This is the piece that makes astype, assignments, and internal copies fast without any Python involvement.
Indexing. The subscript protocol at array_subscript, L1480 handles everything from a plain integer to a boolean mask to fancy indexing — including the np.ndarray specialization paths that decide between a view and a copy.
UFuncs. The ufunc type is implemented in ufunc_object.c. Calling np.add(a, b) reaches ufunc_generic_vectorcall, L5222 — NumPy registers it as the type’s vectorcall slot, so calls skip the generic Python call machinery. From there the engine picks inner loops per dtype, applies broadcasting, and streams over memory. Reductions are handled separately: ndarray.sum() lands in array_reduce, L1859 and ends up inside PyUFunc_ReduceWrapper, L179, the wrapper that manages accumulators, dtype casting of the accumulator, and the where=/initial= semantics. Compound operations like np.add.reduce route through PyUFunc_GenericReduction, L3879.
Extensibility. The __array_function__ protocol — the mechanism libraries like the ones behind pytorch and scikit-learn use to accept ndarray-likes — is enforced in C at get_implementing_args_and_methods, L62, which collects implementing types and picks the override before falling back to NumPy’s own implementation.
The detailed map: namespace shims on top, the Python API surface and numpy.lib implementation modules in the middle, and the two C engine halves — creation/indexing, dtype transfer, and ufunc/reduction — at the bottom.
The NumPy 2.x layout
If you browse the repository you will notice the package is split into public and private halves. The real code lives in numpy/_core/, and numpy/core/ survives only as a compatibility shim: its module docstring, L2 explains that core was renamed _core and made private, and its getattr, L30 forwards attribute access to the new location while raising a deprecation warning. The same shim pattern repeats in numpy/lib/: npyio.py is a one-line re-export of numpy/lib/_npyio_impl.py, format.py re-exports _format_impl.py, and stride_tricks.py re-exports _stride_tricks_impl.py. The public names stayed stable for users; the implementation modules moved behind an underscore.
This layout has a practical consequence for contributors and for anyone reading the source with us today: when you grep for an implementation, search in _core first. Files like numeric.py, fromnumeric.py, and function_base.py that pre-2.x lived in numpy/core/ now sit in numpy/_core/, while genuinely library-level features (IO, histograms, set operations, padding) live in numpy/lib/*_impl.py. The masked-array world remains a world of its own under numpy/ma/.
From Install to Your First Model
NumPy is pure wheels on every mainstream platform — no compiler needed:
pip install numpy
A minimal session that touches the exact code paths we toured above:
import numpy as np
# coercion: Python lists -> PyArray_FromAny -> one contiguous float64 buffer
X = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]])
# a ufunc call: dispatches through ufunc_generic_vectorcall to C loops
Y = np.add(X, 10.0)
# a reduction: mean -> _methods._mean -> umr_sum ufunc -> PyUFunc_ReduceWrapper
print(Y.mean(axis=0)) # [12. 13. 14.]
# a zero-copy view: sliding_window_view builds shape+strides, no data copied
windows = np.lib.stride_tricks.sliding_window_view(Y, window_shape=2, axis=1)
print(windows.shape) # (2, 2, 2)
# persistence: save writes the .npy header plus the raw buffer
np.savez("mini_model.npz", X=X, Y=Y)
Four lines in, you have exercised the constructor, the ufunc dispatcher, the reduction wrapper, the stride tricks view builder, and the .npz container writer — the entire vertical slice of the library. If you then feed X into a linear algebra routine or hand it to a framework built on top of NumPy, the story continues elsewhere: pandas wraps rows of it in labeled containers (see our pandas tour), and training frameworks convert it into device tensors the moment you move to a GPU.
Honest Limits
- The GIL boundary is sharp. All the speed we celebrated lives in C loops that release or avoid the Python GIL only in specific, opt-in situations. Pure-Python loops over array elements remain slow by design; if you find yourself writing
for i in range(X.shape[0]), you are using NumPy against its grain. - UFuncs do not cover everything. Operations without a C inner loop fall back to Python-level generic code, which is orders of magnitude slower. The
numpy.apply_along_axisfamily is a convenience, not a performance tool. - The
_core/libsplit is still settling. NumPy 2.x moved mountains with the_corerename, and the*_impl.pyshim pattern means some symbols exist in three places (public re-export, private impl, C extension). Paths that were correct in 1.x blog posts are wrong onmaintoday. - A single dtype per array. Real-world tables with mixed types get ragged
dtype=objectarrays, which disable every fast path in the C engine. That limitation is exactly the gap structured libraries were built to fill. - Advanced indexing has surprising copies. Boolean masks and fancy indexing produce new arrays; slices produce views. The rules are documented, but the C implementation makes the distinction easy to hit only at memory-usage OOM time.
NumPy’s gift to the ecosystem is restraint: one array object, one function protocol, and a C core disciplined enough that twenty years of libraries — from scikit-learn estimators to the tensor engines behind pytorch — could build on it without ever breaking the vocabulary. Read Enjoyed this post? Never miss out on future posts by following us ctors.c once and X.mean() never looks the same again.