Skip to content
gohacks.dev
Basics

Arrays in Go and Why You Will Rarely Use Them Directly

A Go array has a fixed length that is part of its type, and assigning one copies every element. See how arrays differ from slices and when they are worth it.

6 min read
Summarize in AI
Flat illustration of an editor window full of code.

Every language textbook starts with arrays, and Go's arrays are the least useful part of the language's data structures. That is not a flaw. They are the foundation that slices sit on, and the rare tool for data whose size never changes. If you learned arrays in C or Java, the one thing to unlearn is that an array is a handle you pass around. In Go it is a value, like an int or a struct, and everything unusual about it follows from that. This article shows the copying, comparison and slicing behavior with output, and then where an array is the right call. It belongs in the Go basics path, just before slices, which you will use ten times more.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Declaring an array, and what makes its type

An array type is written with the length in brackets before the element type. The length must be a constant, and it is part of the type:

Go
var a [3]int            // three zeroed ints
b := [3]int{10, 20, 30} // literal
c := [...]string{"x", "y", "z"} // the compiler counts: [3]string
d := [5]int{1: 7, 3: 9} // keyed: [0 7 0 9 0]

Because the length belongs to the type, [3]int and [4]int are different types with no conversion between them. A function that takes [3]int accepts nothing else. That is the first reason arrays stay out of public APIs: every different size needs its own function.

Constant indexes are checked at compile time. This is one of the few times Go catches an index error before the program runs:

main.goGo
package main

import "fmt"

func main() {
	a := [3]int{1, 2, 3}
	fmt.Println(a[5])
}
Text
# example.test/lab
./main.go:7:16: invalid argument: index 5 out of bounds [0:3]

An index that is a variable cannot be checked, so it panics at run time with index out of range, which is the same panic described in index out of range panics in Go.

An array is a value, so it copies

This is the property that surprises people. Assigning an array to another variable copies all of it. Passing an array to a function copies all of it. Modifying the copy leaves the original alone:

main.goGo
package main

import "fmt"

func zeroFirst(a [4]int) {
	a[0] = 0
	fmt.Println("inside:", a)
}

func zeroFirstPtr(a *[4]int) {
	a[0] = 0
}

func main() {
	original := [4]int{9, 8, 7, 6}

	copyOf := original
	copyOf[1] = 100
	fmt.Println(original, copyOf)

	zeroFirst(original)
	fmt.Println("after value call:", original)

	zeroFirstPtr(&original)
	fmt.Println("after pointer call:", original)
}
Text
[9 8 7 6] [9 100 7 6]
inside: [0 8 7 6]
after value call: [9 8 7 6]
after pointer call: [0 8 7 6]

The first print shows two independent arrays. The function that takes the array by value changes only its own copy, so original still starts with 9 afterwards. Taking a pointer is the way to modify in place, and Go lets you index through a pointer to an array without dereferencing it, so a[0] works on *[4]int. If you have written Java, the behavior is the opposite of what you expect: an array variable there is a reference.

The cost scales with size. Passing a [1000000]int to a function copies 8 MB. The compiler may avoid some copies, but you should not count on it. A large array by value is a design smell, and it also tends to land on the heap, which stack versus heap in Go explains.

Comparison and map keys

Arrays are comparable when their elements are, and == compares element by element. That also makes them valid map keys, which is something a slice can never be:

main.goGo
package main

import (
	"crypto/sha256"
	"fmt"
)

type Point [2]int

func main() {
	a := [3]int{1, 2, 3}
	b := [3]int{1, 2, 3}
	fmt.Println(a == b, a != [3]int{1, 2, 4})

	visited := map[Point]bool{}
	visited[Point{0, 1}] = true
	visited[Point{2, 3}] = true
	fmt.Println(visited[Point{0, 1}], visited[Point{9, 9}])

	sum := sha256.Sum256([]byte("hello"))
	fmt.Printf("%T %x\n", sum, sum[:4])

	seen := map[[32]byte]string{sum: "hello"}
	fmt.Println(seen[sha256.Sum256([]byte("hello"))])
}
Text
true true
true false
[32]uint8 2cf24dba
hello

The Point map is the pattern I use most: a grid coordinate as a key, without allocating a struct or building a string like "3,4". The SHA-256 function returns a [32]byte rather than a slice, precisely because a digest has a fixed size and is a value you want to compare and store. This is the best argument for arrays in real code: when size is a fact about the data, the type should say it.

Slicing an array makes a window onto it

An array does not change length, but you can take a slice of it with arr[:] or arr[i:j]. The slice does not copy anything. It points at the array's memory, so writes through either one are visible to the other:

main.goGo
package main

import "fmt"

func main() {
	arr := [5]int{1, 2, 3, 4, 5}
	s := arr[1:4]

	s[0] = 20
	fmt.Println(arr, s, len(s), cap(s))

	arr[3] = 40
	fmt.Println(s)

	for i, v := range arr {
		arr[4] = 500
		if i == 4 {
			fmt.Println("range saw", v, "array has", arr[4])
		}
	}
}
Text
[1 20 3 4 5] [20 3 4] 3 4
[20 3 40]
range saw 5 array has 500

s has length 3 and capacity 4, because capacity runs from the slice's start to the end of the array. Changing s[0] changed arr[1], and changing the array later showed up in the slice. The last loop shows a second consequence of value semantics. range arr iterates over a copy of the array, made before the loop starts, so the write to arr[4] inside the loop is not seen by the loop variable. Ranging over a slice of the array, range arr[:], or over a pointer to it, does see the changes. It is a common source of confusion in code that modifies what it iterates.

Arrays inside structs and multi-dimensional arrays

An array field inside a struct is stored inline, with no pointer and no separate allocation, which makes the struct a single block of memory. That is useful for small fixed buffers, a six byte MAC address or a 16 byte UUID. Two-dimensional arrays are arrays of arrays, [3][3]int for a tic-tac-toe board or a small matrix, laid out row by row. For a grid whose size is known at compile time, such as a game board or a lookup table, this is neat. For anything sized at run time, you need a slice of slices, or one flat slice and an index calculation, which is also faster.

Why slices won

Almost every function in the standard library that takes a sequence takes a slice, because a slice is a three word header pointing at memory, and passing one is cheap whatever the size. A slice can grow with append, be cut with s[i:j] and be passed to a function that does not need to know the length in advance. An array does none of that. The one case where I deliberately choose an array is the key case: if I need a composite key for a map, or a value I compare for equality, a small array is cheaper than a string and safer than a struct I have to remember to keep comparable.

When an array is the wrong tool

Do not pick an array because you know the size today. If a requirement might change the size, a slice costs nothing extra and survives the change. Do not use a large array as a global buffer and pass it by value, and do not use arrays to model "a list of up to N things", where a slice with a capacity does the job and carries its own length. For a queue or a pool, a buffered channel or a slice is the idiom. The specification section on array types and the Go blog's article on slices are the references, and the next article, slices from the header to the backing array, is where everyday use begins.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.