Skip to content
beginner Phase 5 · Arrays, Slices, Maps, and Iteration

Fixed-Size Arrays

Work with arrays whose length is part of their type and copied on assignment.

30m
0 problems
Topic Progress 0%

The Length Is Part of the Type

A latency probe runs at a checkpoint and captures exactly five round-trip samples before it reports anything. You want the compiler to enforce that contract, not a comment above the function. The array type does it for you:

func summarize(samples [5]time.Duration) string {
	var total time.Duration
	for _, s := range samples {
		total += s
	}
	return fmt.Sprintf("avg %v across %d samples", total/len(samples), len(samples))
}

A caller holding four samples cannot compile a call to summarize. Passing six fails too, because [6]time.Duration is not a slightly larger version of [5]time.Duration — it is a different type, exactly as int32 and int64 are different types. Length lives in the type name, and that one fact drives everything else about arrays:

var a [3]int
var b [4]int
a = b // compile error: cannot use b (type [4]int) as [3]int value

Because the compiler knows the size at compile time, an array value is complete on its own. var samples [5]time.Duration allocates five durations, all zeroed, immediately. There is no make, no initialization step, and no nil to check later. The zero value of an array is the zero value of its element type, repeated length times — an array of strings is five empty strings, an array of structs is five zeroed structs.

Arrays also inherit comparability from their elements. Two arrays of the same length compare with == element by element whenever the element type itself is comparable:

name := [2]string{"li", "chen"}
alias := [2]string{"li", "chen"}
fmt.Println(name == alias) // true

Comparability is a capability slices will never have, and it unlocks using fixed byte blocks as map keys later on (Maps: Insert, Lookup, and Delete).

The cost of the compiler knowing the size is that every operation on an array treats it as one indivisible unit of memory. Copying a variable copies all N elements. Passing one into a function passes all N elements. There is no shared header, no window, no aliasing — which makes arrays easy to reason about and expensive to move once N grows. That trade decides where arrays belong in real code, which is the next question.

Every Assignment Is a Full Copy

A small grading service keeps one row of four quiz scores per student. Rows get archived before scores are adjusted, so you reach for a plain copy:

func main() {
	scores := [4]int{70, 85, 90, 60}
	archive := scores   // full copy: four ints duplicated
	scores[1] = 95      // archive[1] is still 85

	adjust(scores)
	fmt.Println(scores[0]) // still 70 — adjust edited its own copy
}

func adjust(row [4]int) {
	row[0] = 100 // writes go into the parameter's private copy
}

Two different places behaved identically: assignment and parameter passing both duplicated every element. This is value semantics, and for arrays it is literal — the language copies the bytes. The function cannot corrupt the caller's row no matter what it does to the parameter, which is exactly the isolation the archive needed.

The same rule applies inside for range, and this is where it surprises people:

scores := [4]int{70, 85, 90, 60}

for _, score := range scores {
	score += 5 // compiles, does nothing visible
}
for i := range scores {
	scores[i] += 5 // correct: write through the index
}

score is a fresh copy of each element per iteration. Modifying it edits the copy, throws it away, and moves on. The second loop receives only the index and writes back into the real array. Whenever a loop is supposed to transform array contents, index-based mutation is the only form that works.

Predicting copies becomes a practical skill once you compare containers. An array copies its elements. A struct containing an array copies the array inside it. A slice, as you will see in Slice Basics and the Slice Header, copies only a three-word header while the elements stay shared. Mixed code that passes both around behaves very differently depending on which type it holds, and interviewers like exactly that question: "what gets copied here?" Start the habit now by asking it of every assignment you write.

Where Arrays Earn Their Place (and Where They Don't)

Production Go rarely declares []int-shaped variables as arrays, yet arrays appear at every boundary where the fixed size is the point. Hash digests are the classic case — sha256.Sum256 returns [32]byte, not a slice:

func dedupeFiles(paths []string) ([]string, error) {
	seen := map[[32]byte]bool{} // comparable key: arrays qualify, slices never do
	var unique []string
	for _, p := range paths {
		data, err := os.ReadFile(p)
		if err != nil {
			return nil, err
		}
		sum := sha256.Sum256(data) // [32]byte value, copied on assignment
		if !seen[sum] {
			seen[sum] = true
			unique = append(unique, p)
		}
	}
	return unique, nil
}

The digest must be a value you can hash, compare, and store as a map key — all things a 32-byte array does natively. The same shape covers MAC tags, IPv6 addresses ([16]byte), and fixed grids. A tic-tac-toe board writes naturally as a nested array, with element types elided inside the literal:

var board [3][3]byte
board[0][2] = 'X'

start := [3][3]byte{
	{'X', 'O', 0},
	{0, 'X', 0},
	{0, 0, 'O'},
}
fmt.Println(len(board), len(board[0])) // 3 3

Notice there is no [][]byte here: a slice of slices would be two separately allocated regions with two headers. The nested array is one contiguous 9-byte block with dimensions baked into the type, so bounds checks need no extra indirection.

Where arrays lose: anything whose length is chosen at runtime, grows over time, or crosses many function boundaries. Moving a 4 KB array through five call layers means five 4 KB copies, and the compiler's escape analysis may even push it to the heap (Heap Escape Analysis). The usual pattern is to own a fixed array internally and hand slices outward:

type Ring struct {
	buf  [64]int // storage stays fixed and private
	fill int
}

func (r *Ring) Values() []int { return r.buf[:r.fill] } // array exposed as a slice

Calling r.buf[:r.fill] builds a slice header over the existing storage without copying anything (Slice Basics and the Slice Header). That is the relationship to remember: arrays are the concrete, sized blocks; slices are the variable-length views that flow through Go APIs. Know the array's strengths — comparability, fixed layout, copy isolation — and let slices carry the data everywhere else.

Quiz

1. A function takes scores [4]int, adds 10 to scores[0], and returns. The caller prints its own scores[0] afterward. What does it print?

Question 1 options

2. You need to store per-sensor readings keyed by a fixed 16-byte device identifier in a map. Which type fits?

Question 2 options

3. This loop is supposed to curve every quiz score up by 5, but scores never changes. Why? for _, s := range scores { s += 5 }

Question 3 options

4. var a [3]string and var b [3]string are declared and never assigned. What does fmt.Println(a == b) print?

Question 4 options

Flashcards

Question

Why does var a [3]int = var b [4]int fail to compile, while int32 and int64 can be converted?

Answer

Array length is part of the type name, so [3]int and [4]int are unrelated types with no conversion syntax. Numeric types at least offer explicit conversion (int64(x)); arrays force you to build a new value element by element or choose a slice instead.

Question

You assign one array variable to another and then mutate the copy. What happens to the original, and why does that matter for function parameters?

Answer

Nothing — assignment duplicates every element, so the two variables own independent storage. Function parameters are assigned the same way, which is why a function receiving [N]T cannot corrupt the caller's array: it edits its own copy.

Question

Name two concrete situations where an array is the better choice over a slice.

Answer

First, fixed-size identity values like [32]byte digests or [16]byte addresses, where the size is part of the meaning. Second, when the value must be a map key or compared with == — slices are not comparable, arrays of comparable elements are.

Question

What does range over an array give you, and what is the classic mutation mistake?

Answer

Each iteration yields the index and a copy of the element at that index. The mistake is mutating the element copy (for _, v := range a { v++ }), which discards the change; correct transformation uses the index: for i := range a { a[i]++ }.

Question

A function must accept variable-length input internally but you want fixed storage. How do you bridge the two?

Answer

Keep a private [N]T field and expose a slice view of it, such as buf[:filled]. Slicing an array creates a header over the same memory with no copy, so callers get slice ergonomics while your struct keeps a fixed, contiguous layout.

Revision Notes

Key Takeaways

  • 1. Array length is part of the type: [3]int and [4]int are unrelated, and neither can be assigned to the other
  • 2. Assignment, parameter passing, and range all copy array elements, so arrays give copy isolation instead of shared state
  • 3. Arrays are comparable when their elements are, which is what makes [32]byte a legal map key while slices are never comparable
  • 4. Mutating through range requires index assignment — the ranged element value is a per-iteration copy
  • 5. The standard bridge is owning a fixed array internally and returning arr[:] so callers see a slice over the same storage

Interview Tips

  • • "What's copied when you pass an array to a function?" — answer with the whole array, then contrast it with a slice header copy in one sentence
  • • "Why can't I use []byte as a map key?" — say 'not comparable', then immediately offer [32]byte as the fixed-size alternative
  • • When asked to deduplicate by hash, write map[[32]byte]bool — it shows you know both the digest shape and the comparability rule
  • • "How do you iterate and modify an array?" — write the index loop, and mention that ranging over the element only edits a copy

Cheat Sheet

Fixed-Size Arrays

Key patterns:

Pattern When to use
var a [N]T N known at compile time, zero value is immediately usable
[N]T{v1, v2} small fixed literals: grids, digests, coordinates
[32]byte value / sha256.Sum256 identity blocks that must be comparable or hashable
arr[:] hand fixed storage to APIs that take slices
index mutation in range transforming elements — never mutate the loop copy

Copying rules:

Operation What is copied
assignment b := a every element
call f(a) every element (parameter)
range a each element, per iteration
struct containing array the whole array, with the struct

Code template:

// Fixed storage, slice-shaped API.
type Ring struct {
	buf  [64]int
	fill int
}

func (r *Ring) Add(v int) {
	if r.fill == len(r.buf) {
		copy(r.buf[:], r.buf[1:]) // shift, no allocation
		r.fill--
	}
	r.buf[r.fill] = v
	r.fill++
}

func (r *Ring) Values() []int { return r.buf[:r.fill] } // view, not a copy

Decision rule: if the length can change, is chosen at runtime, or must flow through many functions, use a slice — reach for an array only when the size is the contract, the value needs ==, or copy isolation is the feature.

Continue Learning