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.

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:
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:
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
fmt.Println(a[5])
}# 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:
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)
}[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:
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"))])
}true true
true false
[32]uint8 2cf24dba
helloThe 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:
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])
}
}
}[1 20 3 4 5] [20 3 4] 3 4
[20 3 40]
range saw 5 array has 500s 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.


