How to Use ndarray for Numerical Computing in Rust

Use the `ndarray` crate to create multi-dimensional arrays and perform numerical operations in Rust.

When Vec<Vec> fails you

You've been writing Python scripts where import numpy as np solves everything. You create a 2D array, slice it, multiply it, and the code runs fast because NumPy is backed by C. Now you're in Rust. You reach for Vec<Vec<f64>> to store a grid of numbers. You write a loop to access grid[i][j]. The compiler complains about lifetimes, or the code runs slow because the memory is scattered everywhere. You need a contiguous block of memory that understands dimensions, strides, and shapes. That's ndarray.

Stop nesting vectors. Get a real array.

What ndarray actually is

ndarray gives you multi-dimensional arrays that live in one contiguous block of memory. Think of a Vec<T> as a single row of lockers. You can add lockers to the end, but you can't easily jump to row 5, column 3 without doing math yourself. ndarray is like a fully mapped warehouse. You tell it the shape: 10 rows, 20 columns. It allocates one big strip of memory and keeps a map of how to navigate it.

The crate tracks three things:

  • Data: The actual values in a contiguous buffer.
  • Shape: The dimensions, like (10, 20).
  • Strides: How many steps in memory to take to move along each axis.

You get array[[row, col]] syntax that feels natural, and the library handles the pointer arithmetic so you don't have to. It supports slicing, broadcasting, and element-wise operations, just like NumPy, but with Rust's safety guarantees. The data stays contiguous, which means your CPU cache stays happy.

Contiguous memory is your friend for cache performance.

Minimal example

Here is the basics: creating a 2D array, mutating an element, and reading it back.

use ndarray::arr2;

/// Create a 2x2 array and modify an element.
fn main() {
    // arr2 creates a 2D array from nested slices.
    // The type is inferred as Array2<f64>.
    let mut matrix = arr2(&[[1.0, 2.0], [3.0, 4.0]]);

    // Indexing uses a slice of indices: [row, col].
    // This accesses row 1, column 1 (the bottom-right corner).
    matrix[[1, 1]] = 99.0;

    // Read back the value at row 0, column 1.
    println!("Value: {}", matrix[[0, 1]]);
}

arr2 takes a nested slice and infers the dimensions. The result is an Array2<f64>, which is an alias for Array<f64, Ix2>. The Ix2 part tells the compiler this is a 2-dimensional array. You can use arr1, arr3, etc., for other dimensions, or use Array::from_shape_vec for dynamic shapes.

Indexing uses the Index and IndexMut traits. When you write matrix[[1, 1]], the library computes the offset in the underlying buffer using the strides. If the indices are out of bounds, ndarray panics in debug mode and may have undefined behavior in release mode, just like standard Rust indexing. Use get if you need safe access that returns Option.

Real-world pattern: Processing a grid

Real code rarely just creates and prints. You usually transform data, slice regions, or combine arrays. This example shows element-wise addition, slicing, and iteration using zip.

use ndarray::{arr2, s, Axis};

/// Process a grid of data: add offsets, slice a region, and iterate.
fn process_grid() {
    // Create a 3x3 array representing sensor readings.
    let readings = arr2(&[
        [10.0, 12.0, 11.0],
        [15.0, 14.0, 16.0],
        [20.0, 19.0, 21.0],
    ]);

    // Create a calibration offset for each column.
    // This is a 1x3 array.
    let offset = arr2(&[[0.5, -0.5, 0.0]]);

    // Broadcasting adds the offset to every row.
    // ndarray checks shape compatibility automatically.
    let calibrated = &readings + &offset;
    println!("Calibrated:\n{}", calibrated);

    // Slice the top-left 2x2 region.
    // s! is a macro for slicing syntax.
    // 0..2 means rows 0 and 1. .. means all columns.
    let region = calibrated.slice(s![0..2, 0..2]);
    println!("Region:\n{}", region);

    // Iterate over rows using axis_iter.
    // Axis(0) is the first dimension (rows).
    for row in calibrated.axis_iter(Axis(0)) {
        // row is a view of one row.
        println!("Row sum: {:?}", row.sum());
    }
}

The s! macro is the standard way to write slices. s![0..2, ..] creates a slice that covers rows 0 to 1 and all columns. Slicing returns a view, not a copy. The view shares the underlying data with the original array. This is zero-cost. You get a new handle with a different shape and offset, but no memory allocation happens.

Broadcasting lets you operate on arrays of different shapes. When you add readings (3x3) and offset (1x3), ndarray sees that the offset has a singleton dimension in the row axis. It expands the offset virtually to match the readings. The offset data is not copied. The operation reads the same offset values for every row. This saves memory and code.

Iteration with axis_iter gives you views of sub-arrays. Axis(0) refers to the first dimension. In a 2D array, Axis(0) is rows and Axis(1) is columns. Using Axis instead of magic numbers makes your code robust. If you refactor to 3D arrays later, Axis(0) still means the first dimension.

Convention aside: The ndarray community prefers Axis enums over hardcoded indices for dimensions. It also prefers zip for element-wise loops over indexing. Indexing in a loop is slower and error-prone. zip guarantees you iterate over matching elements and enables SIMD optimizations.

Pitfalls and compiler errors

ndarray is safe, but it has rules. Breaking them triggers compiler errors or runtime panics.

If you try to mutate an array while holding a view, the borrow checker stops you. Views borrow the underlying data. If you have a mutable view, you cannot access the original array mutably. The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable). Drop the view before mutating the parent.

use ndarray::arr2;

fn borrow_pitfall() {
    let mut data = arr2(&[[1, 2], [3, 4]]);

    // Create a mutable view of the first row.
    let mut first_row = data.slice_mut(s![0, ..]);

    // This line fails to compile.
    // data is borrowed mutably by first_row.
    // data[[1, 0]] = 99; // Error E0502

    // Fix: Use the view to mutate.
    first_row[[0]] = 99;

    // Or drop the view before accessing data.
    drop(first_row);
    data[[1, 0]] = 99;
}

If you pass an array to a function by value, you move it. ndarray arrays are owned. Moving transfers ownership. If you try to use the array after moving it, the compiler rejects you with E0382 (use of moved value). Pass references instead.

use ndarray::{arr2, Array2};

/// Sum all elements in an array.
fn sum_array(data: &Array2<f64>) -> f64 {
    data.sum()
}

fn move_pitfall() {
    let data = arr2(&[[1.0, 2.0], [3.0, 4.0]]);

    // Pass a reference to avoid moving.
    let total = sum_array(&data);

    // data is still usable here.
    println!("Total: {}", total);
}

Shape mismatches cause runtime panics. If you try to add two arrays with incompatible shapes and broadcasting doesn't apply, ndarray panics with a ShapeError. This happens at runtime, not compile time. The compiler cannot check shapes for dynamic arrays. Write tests to verify shapes, or use zip which requires matching shapes at the call site.

Trust the borrow checker on views. If it stops you, you're aliasing.

Decision: ndarray vs the rest

Rust has several crates for numerical work. Pick the right one for your use case.

Use ndarray when you need general-purpose multi-dimensional arrays with slicing, broadcasting, and element-wise operations. It is the workhorse for data processing, image manipulation, and scientific computing where you need flexible shapes and views.

Use nalgebra when you are doing linear algebra like matrix multiplication, eigenvalues, or quaternions for game engines. It provides types like Matrix3<f32> and Vector4<f32> with optimized math routines. It is better for fixed-size matrices and geometric transformations.

Use Vec<Vec<T>> when your data is truly ragged, meaning rows have different lengths, and you don't need mathematical operations. Nested vectors are flexible but slow and memory-inefficient. Only use them when the shape is irregular.

Use faer when you need high-performance linear algebra on large matrices and can handle a slightly more complex API for maximum speed. It focuses on numerical stability and performance for dense linear algebra.

Pick the tool that matches the math, not the syntax.

Where to go next