Week 4: Slices, Arrays & Maps in Depth

These three collection types are the ones you'll reach for in nearly every Go program you write — and slices in particular hide a memory model that trips up almost everyone the first time it bites them. This week builds a real mental model, not just the syntax.

Module 4 of 16 Week 4 of 16 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain the difference between a fixed-size array and a slice
  • Predict when append allocates a new backing array and when it mutates the existing one in place
  • Use the comma-ok idiom to distinguish a missing map key from a zero-value one
  • Choose correctly between an array, a slice, and a map for a given problem

1. Arrays vs. Slices

An array has a fixed length baked into its type — [3]int and [4]int are different, incompatible types. In practice, almost nobody uses arrays directly; a slice — a lightweight view over an underlying array, with a length and a capacity — is what Go code actually uses.

an array vs. a slice
var arr [3]int = [3]int{1, 2, 3} // fixed size, part of the type

nums := []int{1, 2, 3} // a slice literal — no size in the type
fmt.Println(len(nums), cap(nums)) // 3 3

more := make([]int, 3, 10) // length 3, capacity 10
fmt.Println(len(more), cap(more)) // 3 10

A slice is really three things bundled together: a pointer to a backing array, a length (how many elements are in use), and a capacity (how many elements the backing array can hold before it needs to grow). Understanding that triple is the key to everything else this week.

2. append, Capacity & Shared Backing Arrays

append adds an element and returns a slice — but whether that returned slice shares memory with the original depends entirely on whether capacity was available.

the classic shared-backing-array surprise
original := make([]int, 3, 5) // len 3, cap 5 — room to grow
a := original
b := append(original, 99) // capacity available: b shares original's backing array

b[0] = 1
fmt.Println(a[0]) // 1 — a changed too! They share memory.

full := make([]int, 3, 3) // len 3, cap 3 — no room
c := append(full, 99) // capacity exceeded: Go allocates a brand-new backing array

c[0] = 1
fmt.Println(full[0]) // unchanged — c has its own memory now

When append has spare capacity, it writes into the existing backing array and returns a slice pointing at the same memory — any other slice still viewing that array sees the change. When it doesn't, Go allocates a new, larger backing array (typically doubling capacity) and copies the data over, fully decoupling the result from the original. This is the single most common source of "why did an unrelated slice just change" bugs in Go.

3. Maps: comma-ok, Iteration Order & Zero Values

basic map operations
ages := map[string]int{"Asha": 30, "Ravi": 25}

ages["Priya"] = 28      // insert
delete(ages, "Ravi")     // remove
fmt.Println(ages["Zed"]) // 0 — a missing key returns the zero value, no error

That last line is the trap: ages["Zed"] returns 0 whether "Zed" is actually mapped to 0, or isn't in the map at all. The comma-ok idiom is how you tell the two apart.

the comma-ok idiom
value, ok := ages["Zed"]
if !ok {
    fmt.Println("Zed is not in the map")
} else {
    fmt.Println("Zed's age is", value)
}

And a second trap: iterating a map with for k, v := range m visits keys in an intentionally randomized order — different on every run, by design, so code never accidentally comes to depend on an order maps don't actually guarantee. Sort the keys explicitly first if order matters.

4. Choosing the Right Collection

  • Array — rarely used directly; reach for one only when a fixed, compile-time-known size is itself meaningful (a 3x3 game board, a fixed-size hash).
  • Slice — the default for any ordered, growable list.
  • Map — key-based lookup where order doesn't matter, or you explicitly sort when it does.

When passing a slice to a function that might grow it, remember append can return a different backing array than the one passed in — the idiomatic pattern is always to reassign the result (s = append(s, x)), never to assume the original variable was mutated in place.

5. Hands-on Exercise

Hands-on

Build a word-frequency counter

A small program exercising slices, append, and the comma-ok map idiom together.

Requirements:

  1. A function WordCounts(words []string) map[string]int that returns how many times each word appears.
  2. Use the comma-ok idiom explicitly inside WordCounts to increment an existing count vs. initialize a new one — don't rely on the zero-value shortcut without understanding why it also happens to work here.
  3. A function TopN(counts map[string]int, n int) []string that returns the n most frequent words, built by appending to a slice and sorting it.
  4. A short comment demonstrating (with a code snippet, not just prose) the shared-backing-array behavior from this week's second section, using a slice you construct in main.
  5. Confirm your program produces the same top-N words regardless of how many times you run it, despite iterating a map internally.
Hint

If your top-N results seem to differ between runs even though the input is identical, you're likely iterating the counts map directly to build the results slice — map iteration order is randomized by design, so collect into a slice first and sort that slice by count (and by word, as a tiebreaker) rather than trusting iteration order to be stable.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does [3]int and [4]int being different types matter in practice, and why do most Go programs avoid arrays as a result?

Because the length is part of the type itself, a function written to accept [3]int cannot accept a [4]int — there's no way to write a function generic over array length without generics (and even then it's awkward). A slice has no length in its type, so one function signature like func Sum(nums []int) int works for a slice of any length, which is why slices are the practical default.

Q2

Given b := append(a, x), what determines whether mutating b also changes a?

Whether a had spare capacity beyond its length at the time of the append. If it did, append writes the new element into the same backing array and b shares memory with a — mutating one is visible through the other. If capacity was exhausted, Go allocates a new backing array for b, and the two become fully independent.

Q3

Why does ages["Zed"] returning 0 not tell you whether "Zed" is actually in the map?

A map lookup for a missing key returns the zero value for the map's value type rather than an error or a special sentinel — for map[string]int, that's 0, which is indistinguishable from a key that's genuinely mapped to 0. The comma-ok form (value, ok := ages[key]) is the only reliable way to tell the two cases apart.

Q4

Why is it considered a bug to write code that depends on the order for k, v := range someMap visits keys in?

Go deliberately randomizes map iteration order on every run specifically so code can't accidentally come to rely on an ordering the language never promised — relying on it anyway means the program's behavior becomes nondeterministic and can change between runs, or between Go versions, with no warning. Anything that needs a stable order has to sort the keys explicitly.