Multi-Dimensional Arrays

Learn how Go represents grids and matrices using arrays of arrays, including declaration, initialization, indexing, memory layout, and common pitfalls.

How Go Represents Multiple Dimensions

Go does not have a special syntax like [3,4]int for two-dimensional arrays. Instead, you build higher dimensions by nesting arrays: a [3][4]int is an array of length 3 whose elements are arrays of length 4, each holding ints. The outer array is a value that contains the inner arrays directly, not pointers to them. Because arrays are value types, this arrangement still places all elements in one contiguous block of memory — the entire 3×4 grid sits in a single chunk. That means a [M][N]int behaves like a true rectangular grid, even though the type looks like “array of arrays.”

Contiguous Memory:

A multi-dimensional array in Go is stored as a single consecutive block, which makes traversing its elements cache-friendly. Each row follows immediately after the previous one, with no pointer indirection between rows.

This is fundamentally different from a slice of slices ([][]int), where each row is an independent slice header that points to its own backing array. With arrays of arrays, all dimensions are fixed at compile time and the entire structure lives on the stack or in a single heap allocation.

Declaring a Multi-Dimensional Array

To declare a multi-dimensional array, you specify the length of each dimension in brackets. The inner dimensions appear on the right.

var matrix [3][4]int

This creates a 3-row, 4-column grid of integers. All elements start at the zero value for int — which is 0. The variable matrix now holds a complete 3×4 grid initialized to zeroes.

var identity [2][2]float64
fmt.Println(identity)
// Output: [[0 0] [0 0]]

The lengths are part of the type. A [3][4]int and a [4][3]int are different types and cannot be assigned to each other.

Initializing with Array Literals

You can provide initial values using nested composite literals. The outer set of braces corresponds to rows, and the inner braces to columns within each row. Every row must have the same number of elements because the inner array types must match.

board := [3][3]string{
    {"X", "O", "X"},
    {"O", "X", "O"},
    {"X", " ", " "},
}
fmt.Println(board)
// Output: [[X O X] [O X O] [X   ]]

If you leave out values inside a nested literal, the remaining slots in that inner array are filled with the zero value. You do not need to fully specify every element, but the structure must still respect the declared lengths.

Go allows the compiler to infer the outer length if you use ..., but the inner length must always be explicit because it is part of the element type.

matrix := [...][3]int{
    {1, 2, 3},
    {4, 5, 6},
}
// matrix has type [2][3]int

This only works when the outer dimension can be deduced; you cannot use ... for an inner dimension.

Indexing and Modifying Elements

Access an element by providing a row index followed by a column index, each in its own brackets.

val := board[0][2] // "X" (row 0, column 2)
board[2][1] = "O"

The compiler enforces that both indices are within bounds at compile time when using constant indices, and at runtime otherwise. A panic will occur if you access outside the declared dimensions.

Index Out of Bounds Panics:

Using an index that exceeds the length of a dimension causes a runtime panic. For [3][4]int, valid row indices are 0–2 and column indices 0–3. Always ensure your loops respect the actual array lengths, especially when arrays are passed into functions that might not know the size.

Iterating Through All Elements

A straightforward way to traverse a multi-dimensional array is with two nested for loops. The outer loop walks over rows, and the inner loop iterates over columns within each row.

grid := [3][4]int{
    {0, 1, 2, 3},
    {4, 5, 6, 7},
    {8, 9, 10, 11},
}
for i := 0; i < len(grid); i++ {
    for j := 0; j < len(grid[i]); j++ {
        fmt.Printf("grid[%d][%d] = %d\n", i, j, grid[i][j])
    }
}

Using len on the outer array gives the number of rows; len on each inner array gives the number of columns. This prevents hardcoding dimensions and keeps the loop correct if the array size changes (provided the array type itself is still compatible).

You can also use for range on the outer array to obtain each row. However, that gives you a copy of the inner array, not a reference. If you only need to read values, a for range is fine. When modifying elements, keep the index-based loop to avoid operating on a copy.

for i, row := range grid {
    for j, val := range row {
        fmt.Println(i, j, val)
    }
}

Modifying a Row Copy:

In a for range loop, the second variable (the row) is a copy of the inner array. Changing row[j] does not affect the original grid. To modify elements, use the index form grid[i][j] or range over &grid to get a pointer.

Memory Layout and Performance

A [M][N]int occupies exactly M * N * size_of(int) bytes, all in one sequence. The elements are stored in row-major order: the first row occupies the first N positions, followed by the second row, and so on.

For a [2][3]int, the memory looks like this:

| row0[0] | row0[1] | row0[2] | row1[0] | row1[1] | row1[2] |

This contiguous layout means that iterating over the array in the natural row-first order uses the CPU cache efficiently, because accessing grid[i][j] and then grid[i][j+1] touches adjacent memory locations. Jumping across columns in the inner loop (column-major traversal) causes cache misses and worse performance, but the correctness is unchanged.

Because the whole array is a single value, copying it duplicates every element. Assignment, passing as a value to a function, or returning from a function all create a complete copy of the multidimensional array. For small grids this is cheap, but for large matrices it can be expensive.

Passing Multi-Dimensional Arrays to Functions

A function that accepts a multi-dimensional array must include the exact dimensions in its signature. This copies the entire array each time the function is called.

func printBoard(b [3][3]string) {
    for i := 0; i < len(b); i++ {
        fmt.Println(b[i])
    }
}

If you want to avoid copying, pass a pointer to the array instead.

func resetBoard(b *[3][3]string) {
    for i := 0; i < len(b); i++ {
        for j := 0; j < len(b[i]); j++ {
            b[i][j] = " "
        }
    }
}

Note that with a pointer, you still use the same bracket indexing syntax; Go automatically dereferences the pointer when accessing elements.

Copying Large Arrays Is Costly:

Passing a 100×100 int array by value copies 40,000 bytes (assuming 4-byte ints) plus the overhead of the array itself. In performance-sensitive code, prefer a pointer or consider whether a slice might be more appropriate for large, dynamic data.

Multi-Dimensional Arrays vs. True Multi-Dimensional Types

Some languages offer built-in two-dimensional arrays where the dimensions are part of the type itself (e.g., double[,] in C#). Go has no such construct; a [3][4]int is always an array of arrays. Despite the syntax, the memory representation is indistinguishable from a true 2D array because the inner arrays are inlined within the outer array. There is no extra pointer overhead between rows.

The main practical difference is that the two subscript operations are separate: a[i] returns an inner array (a value), which can be used independently, whereas in a true 2D array you cannot slice out a row as a standalone value. This means you can pass a single row of a Go multi-dimensional array to a function expecting a one-dimensional array.

row := board[0] // type [3]string
fmt.Println(row)

This design choice gives you fixed-size grids without any new language machinery.

Common Pitfalls

  • Confusing dimensions: A [3][4]int is three rows of four columns. Forgetting which index is the row and which is the column is a frequent source of bugs, especially when the array is not square.
  • Variable-length requirements: If you need a grid whose size is determined at runtime, arrays won't work because their lengths must be compile-time constants. In that case, a slice of slices ([][]int) or a contiguous slice with manual indexing is the correct choice.
  • Forgetting that assignments copy: When you assign one multi-dimensional array to another, you duplicate all elements. This is fine for small arrays but can silently introduce large memory copies.
  • Index out of range with hardcoded numbers: Using literal integers like 3 in a loop condition can break if the array size changes. Always use len instead.

Keeping It Contiguous:

A [M][N]T array always gives you a contiguous block of memory, regardless of how you create it. If cache locality matters, multi-dimensional arrays are a reliable way to achieve it without needing manual index arithmetic.

A Real-World Example: Tic-Tac-Toe Board

A multi-dimensional array maps naturally to a fixed grid. Here is a small program that initializes a 3×3 board, makes a move, and prints the result.

package main
import "fmt"
func main() {
    // Empty board, all spaces.
    board := [3][3]string{
        {" ", " ", " "},
        {" ", " ", " "},
        {" ", " ", " "},
    }
    // Player X places a mark in the center.
    board[1][1] = "X"
    // Display the board.
    for i := 0; i < len(board); i++ {
        fmt.Println(board[i])
    }
}

Output:

[    ]
[  X  ]
[    ]

The board's dimensions are fixed at 3×3, and the zero value for strings is "", so we initialize with a space to keep the display clean. Functions to check for a winner would accept a [3][3]string and examine rows, columns, and diagonals. Because the array is a value, a function like checkWin(board) receives a snapshot of the board at that moment.

Summary

Multi-dimensional arrays in Go give you fixed-size rectangular grids with contiguous memory and value semantics. They are the right tool when you know the exact dimensions ahead of time and want the compiler to enforce them — game boards, small look‑up tables, and embedded matrix constants all benefit from this.