AlgoMaster Logo

Multidimensional Slices

Last Updated: May 22, 2026

Medium Priority
9 min read

A multidimensional slice is a slice whose elements are themselves slices. The most common form is [][]T, a 2D slice that you can think of as a grid of rows and columns. Real store data is full of these shapes: a price grid for the same products across different stores, a seating chart split into sections, a weekly sales report with one row per day. This lesson covers how to declare, build, index, iterate, and pass [][]T values, when to use a flat 1D slice with computed indexing instead, and how the same ideas extend to three dimensions.

Declaring a [][]T

A [][]T is a slice whose element type is []T. The outer slice holds rows, and each row is its own []T. The declaration uses the same [] syntax twice.

var priceGrid [][]float64 declares the variable without allocating anything. The zero value of any slice type is nil, and [][]float64 is no exception. The outer slice is nil, has length zero, and has no rows yet. Trying to write priceGrid[0][0] = 9.99 on this value panics with index out of range, the same way it would for any nil or empty slice.

The key thing to remember is that [][]float64 does not describe a rectangle. Each row is an independent slice and can have its own length. That flexibility is what makes the type interesting, but it also means the type alone tells you nothing about the shape of the data. You have to allocate the rows yourself.

Building a 2D Slice with a Literal

When you know the values up front, a literal is the most direct way to build a 2D slice. The outer braces hold rows, and each row is its own []T literal.

The outer type [][]float64 is inferred from the literal. Each inner {...} builds a []float64 of length 3 and adds it as one row of the outer slice. You don't have to make every row the same length, the compiler doesn't enforce that for [][]T. Keeping rows the same length is a convention you maintain in code, not something the type system promises.

Literals are fine for fixed test data and small examples. Once the data comes from somewhere outside the program (a file, a request, a database), you'll allocate the rows yourself.

Allocating Uniform Rows with Nested make

When you want a grid of a known shape, you allocate the outer slice with make and then allocate each row with make inside a loop. Both allocations are needed: make([][]float64, rows) only creates the outer slice. Each row inside it is still nil until you assign a slice to it.

The first make allocates 3 row slots, each initially nil. The loop then fills each slot with a freshly allocated []int of length 4, every element initialized to the int zero value 0. After both loops, you have a true 3-by-4 grid that you can read and write at any [i][j] pair.

A single make([][]int, 3, 4) does not work the way it looks. The 4 there is the capacity of the outer slice, not the length of the inner rows. The inner rows are still nil. Two make calls, one for the outer slice and one per row, is the standard pattern.

A nested make does 1 + rows allocations, and each row ends up on its own piece of memory. For an 800-by-800 grid that's 801 separate allocations and 800 non-contiguous backing arrays. The "Flat 1D With Computed Index" section below shows how to collapse this into a single allocation when speed matters.

Each row is a fully independent slice with its own pointer, length, and capacity. Visually, the outer slice acts like a directory of pointers, each entry pointing to a different backing array somewhere in memory.

The outer slice holds three slice headers. Each header points to a separate backing array. Walking row by row is fine, but jumping between rows means jumping between different chunks of memory.

Allocating Jagged Rows

A jagged 2D slice has rows of different lengths. The type [][]T doesn't care, and sometimes the data is genuinely jagged. A reviews-per-product list is a good example: one product has 5 reviews, another has 200, a new product has 0.

Nothing in the type or the syntax forces the rows to match. You can mix make-allocated rows and literal rows freely. The price of this freedom is that any code reading the grid has to ask each row for its length instead of assuming a global column count. This is why iteration over jagged data almost always uses len(row) inside the inner loop.

Indexing m[i][j]

Reading and writing individual cells uses two index operations. The first one selects a row, the second one selects a column inside that row. Both indices are bounds-checked at runtime.

inventory[1] selects the second row, which is itself a []int. Applying [2] to that row picks the third element. Writing inventory[0][3] = 0 updates the value in place inside the backing array of row 0. The outer slice header isn't touched.

If either index is out of bounds, the program panics with runtime error: index out of range. The panic message tells you which dimension failed:

Because each row is an independent slice, you can also pull a row out and work with it directly:

warehouse0 := inventory[0] copies the slice header for row 0, not the backing array. Writing to warehouse0[1] modifies the same memory that inventory[0][1] points at, which is why inventory[0] shows the new value. This is the same shared-backing-array behavior we covered in the _Slice Internals_ lesson, and it applies row by row inside a [][]T.

Iterating With Nested Loops

A standard 2D iteration uses two nested for loops. The outer loop walks rows, the inner walks columns. For uniform grids you can hoist the column count, for jagged grids you ask each row how long it is.

len(row) inside the inner loop is the right habit even for uniform grids. If a future change makes the data jagged, the iteration still works without modification.

range reads more cleanly when you don't need the index. Each loop can use either form independently.

The outer range gives an index and a row slice on each iteration. The inner range walks the row and ignores the column index with _. Mixing range for the outer loop with an indexed for for the inner is also fine when you need column indices.

Modifying Elements Through the Loop

Writing back into cells from a loop is one of the most common operations on a 2D slice. The pattern is straightforward, but there's one detail worth getting right.

Indexing through priceGrid[i][j] writes directly into the backing array of the row. This is the form to use whenever you intend to mutate.

A common mistake is to write into the loop variable instead of the slice:

row in the outer range is a copy of the slice header for that row. It still points at the same backing array, so row[j] =... does mutate the original data. What does not work is writing to row itself (rebinding it with row = somethingElse), since that only changes the loop variable.

Mutating a 2D slice in place is O(rows * cols) and allocates nothing. Building a new grid with append per row reallocates and is several times slower for the same result. Prefer in-place writes when the shape doesn't change.

Passing 2D Slices to Functions

Slices are passed by header, so a function that takes a [][]T receives a copy of the outer slice header. That copy still points at the same row pointers, and each of those pointers still points at the same backing arrays. Writes through the function reach the caller's data.

applyDiscount doesn't take a pointer, doesn't return a new grid, and yet priceGrid in main reflects the changes. That's because the inner backing arrays are shared. gridTotal is the read-only counterpart: it walks the same shared data and returns a single scalar.

A function that needs to replace whole rows or change the shape of the grid is a different story. Rebinding grid[i] = newRow inside the function does reach the caller, because grid[i] writes into the outer slice's backing array. Rebinding grid = newGrid does not, because that only updates the local copy of the outer header. If a function needs to grow or shrink the outer slice and have the caller see it, return the new slice and have the caller reassign, the same rule as for any other slice.

addRow follows the standard append-and-return idiom. The caller reassigns the result. Trying to do this without the return value would lose the new row whenever append had to grow the outer slice.

Uniform 2D as a Flat 1D With a Computed Index

For uniform grids where every row has the same length, a flat 1D slice with index math is often a better fit than [][]T. The idea is simple: store all rows * cols cells in a single []T and compute the position of (i, j) as i*cols + j.

The whole grid lives in one contiguous []float64. There's exactly one allocation regardless of size. Walking the data in row-major order is just walking g.data from index 0 to len(g.data)-1, and the CPU's prefetcher loves that pattern.

A few trade-offs are worth being honest about:

Aspect[][]TFlat 1D + index math
Allocations1 + rows1
Memory layoutRows scattered, each in its own backing arrayAll cells contiguous
Cache localityPoor across rows, good within a rowGood across the whole grid
Indexing syntaxm[i][j] (cheap and clear)g.at(i, j) or data[i*cols+j]
Variable-length rowsEasy (jagged)Not supported without extra bookkeeping
Resizing rows independentlyEasyPainful

For a uniform grid larger than roughly a few thousand cells, a flat []T with i*cols + j indexing is often 2x to 5x faster than [][]T for cache-sensitive loops. The win comes from one allocation, one contiguous backing array, and predictable strides. For small grids the difference is in the noise and [][]T is more readable.

Pick the right shape for the problem. Anything jagged or mutable-in-shape stays as [][]T. Anything uniform, large, and performance-sensitive (image data, sales matrices, embedding tables) is a better fit for the flat layout. Standard library types like image.RGBA use exactly this trick, with the Stride field playing the role of cols.

A Brief Word on 3D Slices

Three dimensions is the same idea applied one more time. [][][]T is a slice of [][]T values, which are themselves slices of []T. A useful e-commerce shape: inventory broken down by warehouse, then by product category, then by individual SKU.

Allocation with make is the obvious extension: one make for the outermost slice, a nested loop allocating each [][]int, and a deeper nested loop allocating each []int. The cache-locality problem gets worse with each added dimension, and the same flat-1D-with-index-math trick generalizes too: for a uniform 3D shape you'd compute i*(cols*depth) + j*depth + k.

Most real programs stop at two dimensions and use a struct or a map of slices when the data is conceptually 3D. [][][]T reads fine for short examples but becomes hard to track in code with many nested accesses.