Array Internals
A Go array is a value: a fixed-length, contiguous block of elements of a single type, where the length is part of the type itself.
[3]intand[4]intare different, incompatible types (Go spec — Array types). Unlike a slice, an array has no header — there is no pointer, no separate backing store; the array variable’s storage is the elements, laid out end to end. The single most important consequence flows from this: arrays have full value semantics. Assigning one array to another, passing an array to a function, or returning one copies every element. This makes arrays predictable and stack-friendly but also makes them a quiet performance footgun when they are large and passed by value, and it is why slices — not arrays — are the workhorse collection of idiomatic Go.
Mental Model
Think of an array as a struct with N identically typed, anonymous fields packed contiguously. It behaves exactly like a struct does: it is copied on assignment, compared field-by-field with == (if the element type is comparable), and lives wherever the variable lives — on the stack if it doesn’t escape, embedded inline inside an enclosing struct or array if it is a field. There is nothing “remote” about an array’s data.
flowchart TD subgraph S["Slice: a header that POINTS at storage"] SH["ptr | len | cap"] --> SB["backing array (elsewhere)"] end subgraph A["Array: the variable IS the storage"] AV["[4]int = e0 e1 e2 e3 contiguously"] end note["Copying a slice copies 3 words.<br/>Copying an array copies ALL elements."]
Figure: the structural contrast. A slice variable holds a 3-word descriptor pointing at storage that lives somewhere else and may be shared. An array variable holds the elements directly — so a copy of the variable is a copy of the data. The insight: ‘array vs slice’ is fundamentally ‘value vs descriptor’, and every behavioural difference follows from that.
Mechanical Walk-through
The type: length is part of identity
The spec’s grammar is ArrayType = "[" ArrayLength "]" ElementType, and the length “is part of the array’s type; it must evaluate to a non-negative constant representable by a value of type int” (Go spec — Array types). The length is therefore fixed at compile time — it cannot be a runtime variable. [3]int and [5]int are distinct types: you cannot assign one to the other, pass one where the other is expected, or even compare them. The element type may itself be an array ([3][5]int is a 3-element array of 5-element arrays of int), and the spec notes the equivalence [2][2][2]float64 is the same as [2]([2]([2]float64)).
One subtle restriction: an array type T may not contain an element of type T directly or indirectly through array/struct nesting — an array cannot be recursively self-referential, because its size would be unbounded (Go spec). (A pointer to T inside is fine; that is how you build trees and lists.)
Memory layout
An array of n elements of type E occupies exactly n * sizeof(E) bytes — no header, no per-element overhead — laid out contiguously in index order. Its alignment is the alignment of E (Go spec — Size and alignment guarantees). Because the layout is dense and contiguous, iterating an array is maximally cache-friendly (see Memory Alignment and False Sharing for why contiguity helps). An array as a struct field is embedded inline — struct{ tag [16]byte; n int } stores the 16 bytes right inside the struct, not behind a pointer. The zero value of [n]E is an array of n copies of E’s zero value, produced recursively (see Zero Values and Memory Initialization); var a [1000]int is a thousand zeroed ints with no allocation call.
Value semantics: the copy
This is the defining behaviour. An assignment b := a between two arrays of the same type copies all elements; b is fully independent of a. Passing an array to a function copies it into the parameter; the function mutates a copy and the caller’s array is untouched. Returning an array copies it out. The spec frames this through assignability: arrays of the same type are assignable, and assignment of a value type copies the value.
Compare this to a slice, whose header copies but whose backing array is shared (see Slice Internals). With an array there is nothing to share — there is no pointer. This is why a function that must modify a caller’s array takes a *[N]E pointer, or — far more idiomatically — takes a slice.
Comparability
Arrays are comparable with == and != iff their element type is comparable (Go spec — Comparison operators). [3]int == [3]int works and compares element-wise; [3][]int == ... is a compile error because slices are not comparable. Comparable arrays can therefore be used as map keys — map[[16]byte]string is legal and common (e.g. keying by a fixed-size hash or IP address), whereas map[[]byte]string is not. This is a genuine, frequently exploited advantage of arrays over slices.
Length and capacity
For an array, len(a) and cap(a) both return the array length, and — because the length is a compile-time constant — len/cap of an array are constant expressions the compiler folds away (Go spec — Length and capacity). len([4]int{}) is the constant 4. For len/cap of a pointer to array, Go applies automatic dereference: len(p) where p is *[N]E returns N.
Indexing, slicing, and range
Indexing a[i] is bounds-checked: an out-of-range index on an array is detected at compile time if i is a constant, otherwise at runtime with a panic (see Bounds Check Elimination). Slicing an array — a[low:high] — produces a slice whose backing array is a’s storage; this requires a to be addressable (you cannot slice an array literal that isn’t stored in a variable). The resulting slice aliases the array: writes through the slice mutate the array. range over an array iterates index/value pairs; note that the array value being ranged is copied per the range semantics (see Range Loop Semantics), so ranging a large array by value has a hidden copy cost — range &a or range a[:] avoids it.
Code Examples
Example 1 — value semantics: passing an array copies it
package main
import "fmt"
func zero(a [5]int) { // a is a COPY of the caller's array
for i := range a {
a[i] = 0
}
}
func zeroPtr(a *[5]int) { // pointer to the caller's array
for i := range a {
a[i] = 0
}
}
func main() {
x := [5]int{1, 2, 3, 4, 5}
zero(x)
fmt.Println(x) // [1 2 3 4 5] -- unchanged: zero mutated a copy
zeroPtr(&x)
fmt.Println(x) // [0 0 0 0 0] -- mutated through the pointer
}zero(x)copies all 5 ints into the parametera; mutatingaleavesxalone.zeroPtr(&x)passes a*[5]int;range aauto-dereferences, and writes hit the caller’s storage.- The idiomatic version of
zerowould take a[]intslice — see Example 3.
Example 2 — arrays as comparable map keys
func main() {
type IPv4 = [4]byte
seen := map[IPv4]int{}
seen[IPv4{192, 168, 0, 1}]++
seen[IPv4{192, 168, 0, 1}]++
seen[IPv4{10, 0, 0, 1}]++
fmt.Println(seen[IPv4{192, 168, 0, 1}]) // 2
fmt.Println([4]byte{1, 2, 3, 4} == [4]byte{1, 2, 3, 4}) // true
// map[[]byte]int{} -- COMPILE ERROR: slice is not comparable
}[4]byteis comparable (element typebyteis comparable), so it is a legal map key and==works element-wise.- The equivalent slice-keyed map does not compile. This — keying by a fixed-size byte block — is one of the few places a plain array is the right choice in everyday Go.
Example 3 — array-backed buffer handed to slice-based APIs
func read(r io.Reader) (int, error) {
var buf [4096]byte // 4 KiB array, stack-allocated if it doesn't escape
return r.Read(buf[:]) // buf[:] is a slice ALIASING the array
}var buf [4096]byteis a fixed array; if escape analysis (see Escape Analysis) proves it does not escape, it lives entirely on the goroutine stack with no heap allocation.buf[:]produces a slice header pointing intobuf—r.Readfills the array’s storage directly. This “stack array + slice view” pattern is a standard zero-allocation idiom for short-lived buffers.
Example 4 — [...] array literal: count inferred
func main() {
primes := [...]int{2, 3, 5, 7, 11} // length inferred as 5
fmt.Println(len(primes)) // 5, a compile-time constant
sparse := [...]int{9: 1} // index 9 set -> length is 10
fmt.Println(len(sparse)) // 10
}[...]T{...}tells the compiler to count the elements and bake the length into the type (Go spec — Composite literals).primeshas type[5]int.- Indexed elements in the literal extend the length to the highest index + 1;
[...]int{9:1}is a[10]intwith index 9 set and the rest zero.
Failure Modes / Common Misunderstandings
“Arrays are slow” — only when copied. An array is not inherently slow; it is the cheapest possible collection when accessed in place (no indirection, perfect locality). The cost is the copy on assignment/pass-by-value/return. A [1024]byte parameter copies a kilobyte on every call. The fix is never “avoid arrays” — it is “pass []T or *[N]T”.
Hidden copy in range. for i, v := range bigArray evaluates and copies the array before iterating (range semantics — see Range Loop Semantics). For a large array this is a silent kilobyte-plus copy per loop. Use for i := range bigArray (index only, no value copy) or range bigArray[:] (range a slice — no copy of the backing array).
Array length must be constant. n := 10; var a [n]int does not compile — n is a variable. Array length must be a compile-time constant. If you need a runtime length, you need a slice and make.
[3]int ≠ [4]int. Length is part of the type. A function taking [3]int rejects a [4]int. Generic code that must work for any length takes a slice, or (since Go 1.18) a type parameter — but even generics cannot abstract over array length. (Go has no value-level generics over array length.)
Slicing requires addressability. [3]int{1,2,3}[:] is a compile error — you cannot slice a non-addressable array literal. Store it in a variable first: a := [3]int{1,2,3}; s := a[:].
An array of pointers does not deep-copy. Copying [3]*T copies the three pointers; the three pointees are still shared. Value semantics apply to the array’s elements — and if the elements are pointers, the pointers (not their targets) are what gets copied.
Alternatives and When to Choose Them
Slices ([]T) — the default. Dynamic length, cheap to pass (3-word header), the entire slices/sort ecosystem targets them. Choose a slice for essentially every collection whose size is not a fixed compile-time constant, and for any large buffer you want to pass without copying. See Slice Internals.
Pointer to array (*[N]E) — when you genuinely want the fixed length encoded in the type and must pass it cheaply or mutate it in place. Rare, but it appears in low-level/unsafe code and in APIs that want the compiler to enforce an exact length at the call site.
Plain array ([N]E) — choose it deliberately for: fixed-size keys/values where value semantics and comparability matter ([16]byte hashes, [4]byte IPs, fixed enum tables); small, stack-local scratch buffers that benefit from zero-allocation; struct fields where inline storage is wanted (no pointer-chase); and compile-time-constant lookup tables. Outside these, reach for a slice.
map[int]T — for sparse or arbitrarily-indexed data where most indices are unused.
Production Notes
The most common production interaction with arrays is invisible: the backing array of every slice is an array, and [N]byte scratch buffers paired with buf[:] slice views are a pervasive zero-allocation idiom in the standard library and high-performance code (bufio, crypto, encoding/hex all do this). Escape analysis is what makes it pay off — a stack-allocated [4096]byte that never escapes costs nothing in GC pressure (see Stack vs Heap Allocation).
The fixed-size-key advantage is real and used in production: crypto-derived keys ([32]byte), IP addresses, and fixed-width identifiers are routinely map keys as arrays, which is impossible with slices. The Go standard library’s netip.Addr (Go 1.18+) is built on a fixed-size representation precisely to be comparable and map-key-able, replacing the older slice-based net.IP.
The recurring bug is the silent large-array copy: a struct embedding a big array, passed by value, copying kilobytes per call; or range over a big array copying it. These are easy to miss in review because the copy is implicit in the language’s value semantics. go vec/govet-style linters and benchmarking with -benchmem surface them. The rule of thumb: arrays of more than a handful of words should be passed by pointer or via a slice, never by value.
See Also
- Slice Internals — the descriptor that points at an array; the workhorse collection
- Map Internals — the other core built-in collection
- String Internals — also fixed-length and immutable, but a 2-word header
- Zero Values and Memory Initialization — the recursively-zeroed array zero value
- Value Semantics and Pointer Semantics — why arrays copy and slices share
- Struct Memory Layout and Alignment — arrays embed inline as struct fields
- Range Loop Semantics — the hidden array copy in
range - Escape Analysis / Stack vs Heap Allocation — when an array stays on the stack
- Bounds Check Elimination — compile-time vs runtime index checks
- Go Internals MOC — parent map of content