Golang Arrays Explained with Examples
Golang 11 min read
Why the length is part of the type, what a copy actually copies, the range loop that iterates a snapshot, and the handful of cases where an array beats a slice.
Most Go code uses slices, and most Go code is right to. Arrays exist underneath them, and the four properties that make arrays awkward as a general container are exactly what make them the correct choice in a few specific places.
Written against Go 1.22.
The length is part of the type
var a [3]int
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3, 4} // compiler counts: [4]int
d := [5]int{2: 10} // sparse: [0 0 10 0 0]
[3]int and [4]int are different types. They cannot be assigned to each other, and a function
taking [3]int will not accept [4]int:
func sum(xs [3]int) int { ... }
sum([4]int{1, 2, 3, 4}) // compile error: cannot use [4]int as [3]int
This is the property that makes arrays impractical as a general parameter type, and it is why slices
exist. A function taking []int accepts any length.
The length must be a constant, known at compile time:
const size = 4
var buf [size]byte // fine
n := 4
var bad [n]byte // compile error: array length n must be constant
len on an array is resolved at compile time, so it costs nothing at runtime.
Arrays are values
The difference from a slice that produces most surprises. Assignment copies the entire array:
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a) // [1 2 3] — unchanged
fmt.Println(b) // [99 2 3]
Compare a slice, where the header is copied and the backing array is shared:
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println(s) // [99 2 3] — the same array
The same applies to function arguments:
func modify(arr [3]int) { arr[0] = 99 } // modifies a copy
func modifyP(arr *[3]int) { arr[0] = 99 } // modifies the caller's array
func modifyS(s []int) { s[0] = 99 } // modifies the caller's data
So passing a [1000000]int by value copies eight megabytes on every call. Pass a pointer or, more
idiomatically, a slice.
Go dereferences an array pointer for indexing and len, so arr[0] works on a *[3]int without
writing (*arr)[0].
The range loop iterates a copy
A consequence of value semantics that is worth its own example:
a := [3]int{1, 2, 3}
for i, v := range a {
a[2] = 99 // modifies the array
fmt.Println(i, v) // prints 1, 2, 3 — never 99
}
fmt.Println(a) // [1 2 99]
range evaluates its expression once, and for an array that evaluation is a copy. The loop walks
the snapshot, so writes to a during the loop are invisible to v.
On a slice, range copies the header, so the same code does see the change, the header points at
the same backing array. Same syntax, different behaviour, decided by whether the operand is an array
or a slice.
If the array is large, range over &a instead. Ranging over an array pointer does not copy:
for i, v := range &a { // no copy of the array
_ = i
_ = v
}
Arrays are comparable
Unlike slices, arrays support == when the element type does:
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true — element by element
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
fmt.Println(s1 == s2) // compile error: slices can only be compared to nil
Which makes arrays usable as map keys, and slices not:
counts := map[[2]int]string{}
counts[[2]int{1, 2}] = "origin pair" // fine
bad := map[[]int]string{} // compile error
That is a genuine use: a fixed-size coordinate, a small composite key, a checksum. When you have a slice and need a key, either copy it into an array of known length or format it into a string — knowing the string form allocates.
Where arrays are the right choice
Five cases, and outside them use a slice.
Fixed-size data whose size is part of its meaning. A hash is 32 bytes, an IPv4 address is 4, a UUID is 16. The type documents it and the compiler enforces it:
func Sum256(data []byte) [32]byte // crypto/sha256 — the length is the contract
sha256.Sum256 returns an array precisely so callers cannot resize or alias the digest.
Avoiding an allocation. An array declared in a function can live on the stack; a slice usually escapes to the heap:
func encode(v uint64) string {
var buf [20]byte // stack, no allocation
return string(strconv.AppendUint(buf[:0], v, 10))
}
buf[:0] makes a zero-length slice over the array, so Append writes into the stack buffer. A common
pattern in hot paths and in the standard library.
A comparable composite: as a map key, or so a containing struct stays comparable. One slice field makes a struct uncomparable; an array field does not.
Inside a struct where the data should be copied with it. A struct containing [4]byte copies that
data on assignment; a struct containing []byte shares it. If the value is genuinely part of the
struct, the array says so.
Multidimensional data of fixed shape. [8][8]Piece for a chessboard is contiguous memory with no
per-row allocation, unlike [][]Piece.
Arrays and slices together
A slice can be taken over an array, sharing its storage:
a := [5]int{1, 2, 3, 4, 5}
s := a[1:4] // []int{2, 3, 4}, backed by a
s[0] = 99
fmt.Println(a) // [1 99 3 4 5] — the same memory
fmt.Println(len(s), cap(s)) // 3 4 — capacity runs to the end of the array
a[:] is the idiomatic way to hand an array to something expecting a slice.
Going back the other way is newer. Go 1.17 added slice-to-array-pointer, and 1.20 added direct slice-to-array conversion:
s := []byte{1, 2, 3, 4}
p := (*[4]byte)(s) // Go 1.17+: pointer, shares memory
arr := [4]byte(s) // Go 1.20+: value, copies
var digest [32]byte
copy(digest[:], someSlice) // works on every version
Both panic if the slice is shorter than the array. The conversion is genuinely useful when an API
returns []byte and another wants [N]byte.
Frequently asked questions
What is the difference between an array and a slice?
An array has a fixed length that is part of its type and is copied on assignment. A slice is a header pointing at an array, with a length and capacity, and copying it shares the data.
Why can I not pass a [4]int where [3]int is expected?
The length is part of the type, so they are different types. Use a slice parameter to accept any length.
Why is my function not modifying the array?
It received a copy. Take a pointer (*[3]int) or use
a slice.
Why does my range loop not see changes made inside it?
range evaluates the array once, which
copies it. The loop walks the snapshot. Range over &a to avoid the copy, or use a slice, where the
header points at shared data.
Can I use a variable for the array length?
No. It must be a compile-time constant. Use a slice with
make for a runtime-determined length.
Can arrays be compared with ==?
Yes, element by element, if the element type is comparable. Slices cannot be compared to anything but nil.
Can an array be a map key?
Yes, when its element type is comparable. That is one of the main practical reasons to use one.
When should I prefer an array over a slice?
When the size is fixed and meaningful (a 32-byte hash), to avoid a heap allocation in a hot path, when you need comparability, or for fixed-shape multidimensional data.
How do I convert between them?
a[:] gives a slice over an array. [4]byte(s) copies a slice into
an array (Go 1.20+), (*[4]byte)(s) takes a pointer to it (Go 1.17+). Both panic if the slice is too
short.
Does len() on an array cost anything?
No. It is a compile-time constant.