Sunday, August 16, 2026
How to Safely Do 2D Slices in Go Without the Footguns
Posted by

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.