Back to blog

Sunday, August 16, 2026

How to Safely Do 2D Slices in Go Without the Footguns

Title card for 'How to Safely Do 2D Slices in Go Without the Footguns' with a short code example

There is no matrix type. [][]T is a slice whose elements are slice headers. make([][]int, rows) allocates the outer list only. Every inner header starts as nil. Indexing m[0][0] panics until you make each row. Copying the outer slice copies headers, not cells. If you slice one backing array into rows, lock capacity or append on a row will walk into the next row.

The failure

This looks like a 3×3 grid of zeros. It is three nil rows.

package main

import "fmt"

func main() {
    m := make([][]int, 3)
    fmt.Println(m) // [[] [] []]
    m[0][0] = 1    // panic: index out of range [0] with length 0
}

The second trap is a "copy" that is not a copy:

a := [][]int{{1, 2}, {3, 4}}
b := a
b[0][0] = 9
fmt.Println(a[0][0]) // 9

b := a copies one outer header. Both still point at the same inner slices.

The third trap is one big buffer, sliced into rows without a capacity cap:

data := make([]int, 6)
m := make([][]int, 2)
m[0] = data[0:3] // cap 6: leftover room is the rest of data
m[1] = data[3:6]
m[0] = append(m[0], 99)
fmt.Println(m[1][0]) // 99: row 1 overwritten

Why this happens

A slice is one-dimensional. A 2D structure is a directory of those headers. Each row owns (or shares) an array. Nothing in the type system forces rows to be the same length. Nothing allocates the cells when you allocate the directory.

That is useful: jagged tables, Pascal's triangle, rows that grow independently. It is also why copy(dst, src) on [][]int copies headers. The rows still alias.

A multi-dimensional array [3][4]int is different: the size is the type, the block is one value, every row is the same length, and assignment copies the whole grid.

The fix

Initialize every row:

rows, cols := 3, 4
m := make([][]int, rows)
for i := range m {
    m[i] = make([]int, cols)
}
m[0][0] = 1

Literals when the data is known:

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

One allocation, rows that cannot append into each other, full slice expression:

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

Deep copy when the grid must not alias:

func clone2D(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
}

Use [n][m]T when both dimensions are compile-time constants and you want one contiguous value. Use [][]T when sizes are runtime, jagged, or you need append on a row.