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]int and [4]int are 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 keysmap[[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 parameter a; mutating a leaves x alone.
  • zeroPtr(&x) passes a *[5]int; range a auto-dereferences, and writes hit the caller’s storage.
  • The idiomatic version of zero would take a []int slice — 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]byte is comparable (element type byte is 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]byte is 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 into bufr.Read fills 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). primes has type [5]int.
  • Indexed elements in the literal extend the length to the highest index + 1; [...]int{9:1} is a [10]int with 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