Two-Dimensional and Nested Slices

Learn how to create and work with two-dimensional slices in Go using row-by-row allocation, single-allocation contiguous memory strategies, and understand jagged slice structures.

A slice in Go is always one‑dimensional — it describes a contiguous segment of an underlying array. When you need a grid, a table, or any two‑dimensional collection, you combine slices into a slice of slices. The outer slice holds a header for each row; each row is itself a slice. There is no built‑in matrix type. The pattern is [][]T, where T is the element type you care about.

This document covers how to allocate, use, and reason about these nested slices, including the trade‑offs between the idiomatic row‑by‑row approach and a less common but sometimes useful single‑allocation strategy.

How a 2D Slice Is Laid Out in Memory

Think of [][]int as a directory. The outer slice is a list of references — each reference is a slice header that contains a pointer, a length, and a capacity. Each inner slice independently points to its own backing array. Unless you take deliberate steps, those backing arrays are not guaranteed to sit next to each other in memory.

This means a 2D slice is a collection of independent rows, not a single block. It is more like an array of variable‑length rows than a dense matrix. That distinction explains most of the surprises beginners encounter: every row must be created explicitly, and copying the outer slice does not copy the row data.

Creating 2D Slices

There is no make([][]int, rows, cols) that creates a fully initialized matrix in one call. You always need at least two steps: make the outer slice, then populate it with inner slices.

Row‑by‑Row Allocation

This is the idiomatic way. Create the outer slice of the desired number of rows, then loop over it and allocate each row with make.

rows, cols := 4, 5
matrix := make([][]int, rows)
for i := range matrix {
    matrix[i] = make([]int, cols)
}

After this loop, matrix is a 4×5 grid of zeros. Every row is a separate slice with its own backing array, each of length 5 and capacity 5.

The code is explicit and readable. A beginner reading it sees immediately that every row gets its own allocation. If the matrix is small, the overhead is negligible.

Forgetting to initialize inner slices:

Accessing matrix[0][0] when matrix[0] is nil causes a runtime panic: index out of range [0] with length 0. The outer make([][]int, rows) only creates the outer slice filled with nil rows. You must initialize each row before you can use it.

Using Composite Literals

When the data is known at compile time, you can define a 2D slice directly with a literal. The inner slices are allocated automatically.

board := [][]string{
    {"X", " ", "O"},
    {"O", "X", " "},
    {" ", "O", "X"},
}

Each row may even have a different length. The compiler infers the outer slice length from the number of rows. This is often the cleanest choice for test tables, small grids, or configuration data.

Single‑Allocation Contiguous Strategy

If you care about cache locality — for example, when processing huge matrices in tight loops — you can allocate all the cells in one backing array and slice it into rows. The rows then share the same underlying array and sit next to each other in memory.

rows, cols := 4, 5
data := make([]int, rows*cols)   // single backing array
matrix := make([][]int, rows)
for i := range matrix {
    matrix[i] = data[i*cols : (i+1)*cols : (i+1)*cols]
}

The expression data[i*cols : (i+1)*cols : (i+1)*cols] creates a slice where length and capacity are both cols. Locking the capacity with the third index prevents an append on one row from spilling into the memory of the next row. Without that lock, a row could silently overwrite the beginning of the following row.

Capacity locking is easy to miss:

Omitting the third index — data[i*cols : (i+1)*cols] — leaves the row’s capacity extending to the end of data. An append on one row can corrupt the next row without any panic. If you use the contiguous strategy, always lock the capacity unless you are certain no row will ever be appended to.

A generic helper (Go 1.18+) eliminates the repetition:

func Make2D[T any](rows, cols int) [][]T {
    data := make([]T, rows*cols)
    matrix := make([][]T, rows)
    for i := range matrix {
        matrix[i] = data[i*cols : (i+1)*cols : (i+1)*cols]
    }
    return matrix
}

Now matrix := Make2D[float64](3, 4) gives you a 3×4 matrix in a single allocation.

When you later iterate over the matrix, the hardware prefetcher sees a long contiguous run of data, which can measurably improve throughput for numerical workloads. The row‑by‑row method may scatter rows around the heap, leading to more cache misses.

For most programs, the idiomatic row‑by‑row approach is perfectly fine. Reach for the single‑allocation pattern only when profiling shows memory access is your bottleneck.

Working with 2D Slices

Accessing Elements

Access an element with two indices: matrix[row][col]. The first index picks the inner slice, the second picks the element inside it.

matrix := Make2D[int](2, 3)
matrix[0][1] = 42
fmt.Println(matrix[0][1]) // 42

Iteration

A nested for/range loop walks the structure naturally.

for i, row := range matrix {
    for j, val := range row {
        fmt.Printf("matrix[%d][%d] = %d\n", i, j, val)
    }
}

If you need to know the number of rows and columns, use len(matrix) and len(matrix[0]). Remember that a 2D slice created row‑by‑row may have rows of different lengths, so only rely on len(matrix[0]) when you have validated that all rows exist and have the same length.

Jagged (Ragged) Slices

Because each inner slice is independent, you can have rows of different lengths. This is sometimes called a jagged or ragged array.

triangle := make([][]int, 3)
for i := range triangle {
    triangle[i] = make([]int, i+1)
}
// triangle[0] has length 1, triangle[1] length 2, triangle[2] length 3

Jagged slices are handy for Pascal’s triangle, sparse structures, or any table where rows carry varying amounts of data. With multi‑dimensional arrays ([n][m]T) every row has the same length — a 2D slice gives you that extra flexibility.

Verify your matrix is correctly initialized:

A quick sanity check: print the outer slice. If you see [[0 0 0] [0 0 0]], your inner slices were created successfully. If you see [[] [] []], the inner slices exist but have length zero. If you see a panic, they were never allocated.

Common Mistakes and Pitfalls

Shallow copy of the outer slice — Assigning a 2D slice to another variable copies only the outer slice header. Both variables share the same inner slices. Modifying a cell through one variable is visible through the other.

a := Make2D[int](2, 2)
b := a
b[0][0] = 99
fmt.Println(a[0][0]) // 99

To get an independent copy, you must deep‑copy every row.

func DeepCopy2D(src [][]int) [][]int {
    dst := make([][]int, len(src))
    for i := range src {
        dst[i] = make([]int, len(src[i]))
        copy(dst[i], src[i])
    }
    return dst
}

Using copy on the outer slice — The built‑in copy only understands a flat slice. If you try copy(dst, src) where both are [][]int, Go copies slice headers, not the row data. The result is the same shallow copy as plain assignment.

Assuming contiguous rows — With row‑by‑row allocation, matrix[0] and matrix[1] are two different backing arrays. Passing a single row to a function and growing it with append does not affect other rows. That isolation is a feature, but it also means you cannot treat the whole matrix as a single []T for bulk operations.

Capacity leak in contiguous slices — As mentioned earlier, if you skip the third index in the slice expression, a row retains extra capacity. This is a silent bug waiting to happen. Always lock the capacity in the contiguous pattern.

When to Prefer an Array Instead

If the grid size is fixed and known at compile time, a multi‑dimensional array [n][m]T is simpler and allocates the entire memory block automatically.

var grid [3][3]byte  // 9 contiguous bytes, fully zeroed
grid[1][1] = 1

You can then take slices over the whole array or over individual rows when you need the flexibility of slices. Arrays are value types, so passing them copies the entire data, which may be expensive. A pointer to an array or a slice derived from it avoids that copy.

Summary

A two‑dimensional slice in Go is a slice of slices — an outer directory of row headers, each pointing to its own backing array. The creation pattern make + for loop is the canonical approach and gives you independent, safely appendable rows. For performance‑sensitive numeric code, the single‑allocation strategy keeps data contiguous and cache‑friendly, provided you lock each row’s capacity.

The fact that each row is a separate slice also means rows can have different lengths. This jagged capability is natural in a slice‑of‑slices world and is something fixed‑size arrays cannot offer.

Because the underlying arrays of rows are shared when you copy the outer slice, any mutation propagates unless you perform a deep copy.