Notes · Learn Go › LESSON 5 OF 8

Collections

Arrays, slices, and maps - plus the slice trap that catches every Python dev

Contents
  1. Arrays: fixed-size shelves
  2. Slices: the real workhorse
  3. The slice-sharing gotcha
  4. append: the magic and the trap
  5. Maps: typed dicts
  6. range: iterating safely
  7. Real-world examples
  8. Gotchas
  9. Glossary

Python's list is a single concept. Go splits it into two: arrays (fixed-size, rarely used directly) and slices (dynamic, the thing you'll use everywhere). The split is important because it exposes something Python hides: what memory do you actually own? That question is the source of Go's most famous gotcha.

Arrays: fixed-size shelves

A Go array has a fixed size that is baked into its type at compile time. You cannot add or remove elements after creation.

arrays.go
var a [5]int              // [0 0 0 0 0] - zero-initialized
b := [3]string{"a", "b", "c"}
c := [...]int{10, 20, 30}   // compiler counts: [3]int

a[0] = 42
fmt.Println(a[0], len(a))  // 42 5

The size is part of the type

This surprises Python developers. [5]int and [6]int are completely different types. You cannot pass a [5]int to a function that expects a [6]int:

type_mismatch.go
func sum(arr [5]int) int { ... }

a := [5]int{1, 2, 3, 4, 5}
b := [6]int{1, 2, 3, 4, 5, 6}

sum(a) // OK
sum(b) // compile error: cannot use [6]int as [5]int

Arrays are values - assigning copies them

Unlike Python lists, Go arrays are value types. Assigning one to another copies all the data:

array_copy.go
a := [3]int{1, 2, 3}
b := a          // b is a full copy
b[0] = 99
fmt.Println(a) // [1 2 3] - a is unchanged
fmt.Println(b) // [99 2 3]
Python - list
# Python lists are always dynamic
# No fixed-size equivalent
x = [1, 2, 3]
x.append(4)    # always works
y = x          # y is a reference!
y[0] = 99      # x[0] is now 99 too
Go - array
// Arrays are fixed-size value types
x := [3]int{1, 2, 3}
// x = append(x, 4) -- won't compile
y := x         // y is a full copy
y[0] = 99      // x[0] is still 1
Practical note: You will almost never use arrays directly in Go. They exist because slices are built on top of them. Once you understand that relationship (coming up next), arrays will make sense. Until then, treat arrays as the foundation, and slices as the tool you actually reach for.
The 5-year-old version
An array is like a shelf at a hardware store with a fixed number of slots welded together at the factory. The shelf with 5 slots and the shelf with 6 slots are different products - they don't even fit in the same space. You can put things in the slots and swap them out, but you can never add a new slot. And if you photograph the shelf (copy the array), you get a brand new separate shelf with the same items in it - moving something on the copy doesn't affect the original.

Slices: the real workhorse

Slices look like Python lists on the surface. But under the hood they are a small struct - a header - that points into a backing array. This distinction is everything.

slices.go
// Three ways to create a slice
s1 := []int{1, 2, 3}           // literal
s2 := make([]int, 5)           // len=5, cap=5, all zeros
s3 := make([]int, 0, 10)       // len=0, cap=10 (pre-allocated)

fmt.Println(len(s1), cap(s1))  // 3 3
fmt.Println(len(s2), cap(s2))  // 5 5
fmt.Println(len(s3), cap(s3))  // 0 10

s1 = append(s1, 4)             // grow it
fmt.Println(s1)               // [1 2 3 4]

Diagram 1: The slice header anatomy

Every slice is exactly three fields: a pointer to the first element, a length, and a capacity. The actual data lives separately in a backing array.

slice header (on the stack) ptr *int (memory address) len = 3 how many elements you can see cap = 6 total slots in backing array backing array (on the heap) 1 [0] 2 [1] 3 [2] - [3] - [4] len = 3 (what you see) cap = 5 (total allocated) 24 bytes total (on 64-bit) data lives here - separate from the header
A slice is just a 24-byte header. The pointer inside it tells Go where the real data lives in the backing array. This is why slices can share memory.

Length vs capacity

PropertyWhat it meansHow to read it
length The number of elements you can currently access via indexing. s[0] through s[len(s)-1]. len(s)
capacity The total number of slots in the backing array from the slice's start position. Extra slots exist so append doesn't always need to allocate. cap(s)

Diagram 2: Sub-slicing shares the backing array

When you write s[1:4], Go does not copy the data. It creates a new slice header with a different pointer (into the same backing array) and different len/cap values.

backing array 10 [0] 20 [1] 30 [2] 40 [3] 50 [4] a := []int{10,20,30,40,50} ptr -> [0] len=5 cap=5 sees all 5 elements b := a[1:4] ptr -> [1] len=3 cap=4 sees elements [1],[2],[3] b sees these 3 cells No data was copied. Both headers point into the same memory.
After b := a[1:4], there are two slice headers but only one backing array. The orange cells are visible through both windows.
Python - slicing COPIES
a = [10, 20, 30, 40, 50]
b = a[1:4]   # new list: [20, 30, 40]
b[0] = 99
print(a)     # [10, 20, 30, 40, 50]
             # a is unchanged
Go - slicing SHARES MEMORY
a := []int{10, 20, 30, 40, 50}
b := a[1:4]  // [20 30 40] - shared!
b[0] = 99
fmt.Println(a) // [10 99 30 40 50]
              // a[1] changed!
The 5-year-old version
Imagine a long row of numbered lockers at a train station. A slice is a laminated card that says "start at locker 5, you can see 3 lockers, and there are 8 total in your section." The card itself is tiny, but it points at real lockers.

When you make a sub-slice, you get a new card that says "start at locker 6, you can see 2." But the lockers are the same lockers. If someone uses the new card to put a different item in locker 6, then the person holding the old card will also see the new item when they look in locker 6. Both cards, same lockers.

The slice-sharing gotcha

This is the most famous Go trap. It bites almost every Python developer the first time, because Python's slicing always copies and this one doesn't.

The code that looks innocent but isn't

gotcha.go
a := []int{1, 2, 3, 4, 5}
b := a[1:3]   // b = [2, 3] -- or so you think

b[0] = 99    // "I'm just modifying b..."

fmt.Println(a) // [1 99 3 4 5]  <-- surprise!
fmt.Println(b) // [99 3]

Diagram 3: Before and after - the gotcha made visible

BEFORE: b := a[1:3] 1 a[0] 2 a[1]/b[0] 3 a[2]/b[1] 4 a[3] 5 a[4] a: ptr->[0] len=5 sees [1,2,3,4,5] b: ptr->[1] len=2 sees [2,3] AFTER: b[0] = 99 1 a[0] 99 CHANGED! 3 a[2]/b[1] 4 a[3] a: [1, 99, 3, 4, 5] a[1] changed too! b: [99, 3] b wrote the 99 here b[0] = 99 wrote to the backing array. a also points there, so a[1] is now 99. Neither a nor b "owns" the array - they share it.
Both slice headers point into the same backing array. Writing through b is writing to shared memory - a sees it immediately.

How to fix it: make a real copy

Use the builtin copy() or the append-to-empty-slice idiom:

fix_sharing.go
a := []int{1, 2, 3, 4, 5}

// Option 1: copy() - explicit and readable
b := make([]int, 2)
copy(b, a[1:3])

// Option 2: append to empty slice - idiomatic
b = append([]int{}, a[1:3]...)

// Now b is independent
b[0] = 99
fmt.Println(a) // [1 2 3 4 5]  -- safe!
fmt.Println(b) // [99 3]

The append-past-capacity twist

Here's where it gets subtle. If you append to a sub-slice and the sub-slice's capacity is exceeded, Go allocates a new backing array. After that point, the two slices are truly separate. But before that, any appends that fit in the existing capacity will still modify the shared backing array and may overwrite data that the original slice can see.

append_twist.go
a := make([]int, 3, 6)    // len=3, cap=6: [0, 0, 0, _, _, _]
a[0], a[1], a[2] = 1, 2, 3

b := a[1:2]              // b = [2], len=1, cap=5 (shares backing array!)

// This append FITS within b's capacity (cap=5, len=1 -> len=2)
// So it writes into a's backing array at position [2]
b = append(b, 99)
fmt.Println(a)           // [1 2 99]  -- a[2] was overwritten!
fmt.Println(b)           // [2 99]

// Keep appending past capacity (cap=5) -> new backing array
b = append(b, 10, 20, 30, 40, 50)
b[0] = 777
fmt.Println(a)           // [1 2 99]  -- a is now unaffected (b moved)
The rule of thumb: if you got a slice by sub-slicing another, treat it as shared until you have proven otherwise with copy(). The moment you want independence, make a copy explicitly - do not rely on capacity overflow to save you.
The 5-year-old version
Sharing a slice is like handing someone a window into your house. They can move your furniture around without telling you. The only way to stop this is to give them a full photograph of the room instead of a window - that's what copy() does.

The append twist is like: if they try to cram more furniture in and the room is full, the moving company gives them an entirely new house. From that point on, they're moving into their place, not yours. But up until that moment, it was your living room.

append: the magic and the trap

append is a builtin function that returns a new slice. Notice that word returns - it does not modify the slice in place. This trips up every Go newcomer at least once.

append_basics.go
s := []int{1, 2, 3}

// WRONG: the return value is discarded
append(s, 4)
fmt.Println(s) // [1 2 3] - nothing happened!

// CORRECT: always assign the result back
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]

// Append multiple values at once
s = append(s, 5, 6, 7)

// Append a whole slice (note the ... spread)
extra := []int{8, 9}
s = append(s, extra...)

Diagram 4: append within capacity vs past capacity

Scenario A: append within capacity s = make([]int, 3, 6) then append(s, 4) Before: 1 2 3 _ _ _ len=3, cap=6 After: 1 2 3 4 _ _ Same backing array - 4 written in place. len=4, cap=6. Scenario B: append past capacity s = []int{1,2,3} (len=3, cap=3) then append(s,4) Old array (full): 1 2 3 abandoned cap exceeded - allocate new array (cap ~6) New array: 1 2 3 4 _ len=4, cap=6 (or similar). s now points here. Growth strategy: Go roughly doubles capacity each time it needs to reallocate. This is why you pre-allocate with make([]T, 0, expectedSize) when you know the size upfront.
When capacity is available (left), append writes in place. When not (right), a new bigger array is allocated and data is copied over. The old array is abandoned.

Why you always write s = append(s, x)

Because append returns the (possibly new) slice header. If a reallocation happened, the pointer inside the header changed. If you discard the return value, you might be holding a stale header pointing at old memory.

append_pattern.go
// Pre-allocate when you know the approximate size
// This avoids repeated reallocation as you fill it
results := make([]string, 0, 100)
for i := 0; i < 100; i++ {
    results = append(results, computeItem(i))
}

// Spread operator: append all items from another slice
a := []int{1, 2, 3}
b := []int{4, 5, 6}
c := append(a, b...)   // c = [1 2 3 4 5 6]

Maps: typed dicts

Go maps are Python dicts with typed keys and typed values. The syntax is a bit different, and there are two important gotchas you won't find in Python.

maps.go
// Create a map
m := map[string]int{
    "alice": 30,
    "bob":   25,
}

// Or with make (nil-safe, ready to write)
scores := make(map[string]int)

// Set / update
m["carol"] = 28

// Read
age := m["alice"]           // 30
missing := m["nobody"]     // 0  -- zero value, no error!

// Two-value form: check existence
v, ok := m["bob"]
if !ok {
    fmt.Println("key not found")
}

// Delete
delete(m, "bob")

// Iterate (order is randomized!)
for k, v := range m {
    fmt.Printf("%s is %d\n", k, v)
}

The two-value form is essential

In Python, d["missing"] raises KeyError. In Go, it returns the zero value silently. To tell if a key was actually set, you must use the two-value form:

map_ok.go
counts := map[string]int{"a": 0}

// Both "a" (set to 0) and "b" (never set) return 0
fmt.Println(counts["a"]) // 0
fmt.Println(counts["b"]) // 0  -- misleading!

// The ok form tells you the truth
_, okA := counts["a"]  // okA = true
_, okB := counts["b"]  // okB = false
Python - dict
d = {"a": 1}
print(d["a"])     # 1
print(d["x"])     # KeyError!
print(d.get("x")) # None
del d["a"]
for k, v in d.items():
    print(k, v)
Go - map
m := map[string]int{"a": 1}
fmt.Println(m["a"])     // 1
fmt.Println(m["x"])     // 0 (no error!)
v, ok := m["x"]        // v=0, ok=false
delete(m, "a")
for k, v := range m {
    fmt.Println(k, v)
}

Map gotchas

Nil maps panic on write, but are safe to read.
var m map[string]int   // nil map
fmt.Println(m["x"])   // 0   - safe
m["x"] = 1            // panic: assignment to entry in nil map

Always initialize maps with a literal or make() before writing.

Iteration order is randomized on purpose.

Go deliberately randomizes map iteration order to prevent code from accidentally relying on it. If you need sorted output, collect the keys, sort them, then iterate:

sorted_map.go
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { fmt.Println(k, m[k]) }

range: iterating safely

range is Go's unified iteration syntax. It works on slices, maps, strings, and channels. A quick tour of the patterns you'll use in every program:

range_patterns.go
nums := []int{10, 20, 30}

// Index and value
for i, v := range nums {
    fmt.Printf("[%d] = %d\n", i, v)
}

// Index only
for i := range nums {
    nums[i] *= 2  // modifying through index works
}

// Value only (discard index)
total := 0
for _, v := range nums {
    total += v
}

// Map iteration
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
    fmt.Println(k, v)  // order not guaranteed
}

// String iteration: yields runes (Unicode code points), not bytes
for i, r := range "Hello" {
    fmt.Printf("%d: %c\n", i, r)
}

The range-value-is-a-copy trap

When you write for i, v := range slice, v is a copy of the element. Modifying v does not modify the original slice. This catches Python developers who expect the equivalent of iterating over a list of mutable objects.

range_copy.go
type Counter struct{ N int }

counters := []Counter{{1}, {2}, {3}}

// WRONG: v is a copy
for _, v := range counters {
    v.N++  // modifies the copy, not the slice element
}
fmt.Println(counters) // [{1} {2} {3}] - unchanged!

// CORRECT: use the index to modify in place
for i := range counters {
    counters[i].N++
}
fmt.Println(counters) // [{2} {3} {4}]
Strings and Unicode: range over a string iterates runes (Unicode code points), not bytes. The index is the byte offset of the rune, not a sequential integer. This matters for multi-byte characters. If you need raw bytes, iterate over []byte(s) instead.

Real-world examples

BackendParsing HTTP query parameters

r.URL.Query() returns a map[string][]string - every key maps to a slice of values (because a query param can appear multiple times). Here's how you work with it:

handler.go
func searchHandler(w http.ResponseWriter, r *http.Request) {
    params := r.URL.Query()  // map[string][]string

    // Single value - use Get() for convenience
    q := params.Get("q")          // "" if missing

    // Multi-value: ?tag=go&tag=cloud
    tags := params["tag"]        // []string{"go", "cloud"}

    // Build response lines with append
    lines := make([]string, 0, len(tags))
    for _, tag := range tags {
        lines = append(lines, "tag:"+tag)
    }

    fmt.Fprintf(w, "query=%s tags=%v\n", q, lines)
}
CLIBuilding output line-by-line

A common CLI pattern: collect output lines into a slice as you process, then write them all at once. Clean separation between logic and output:

status_cmd.go
func runStatusCmd(services []string) {
    output := make([]string, 0, len(services))

    for _, svc := range services {
        status, err := checkService(svc)
        if err != nil {
            output = append(output, svc+": ERROR")
            continue
        }
        output = append(output, svc+": "+status)
    }

    for _, line := range output {
        fmt.Println(line)
    }
}
k8sFiltering a list of Pods

The Kubernetes client returns []corev1.Pod. You often need to filter down to a sub-set. The idiomatic Go way - no lambda, no list comprehension, just a loop and append:

pod_filter.go
func getRunningPods(pods []corev1.Pod) []corev1.Pod {
    running := make([]corev1.Pod, 0)

    for _, pod := range pods {
        if pod.Status.Phase == corev1.PodRunning {
            running = append(running, pod)
        }
    }
    return running
}

// Usage
allPods, _ := clientset.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
runningPods := getRunningPods(allPods.Items)
fmt.Printf("%d of %d pods are running\n",
    len(runningPods), len(allPods.Items))

Note: pod is a copy on each iteration, which is fine here since we're only reading it and appending to a different slice.

Gotchas (collections generate a lot of these)

1. Sub-slices share the backing array - modifying one affects the other.

b := a[1:3] does not copy. b[0] = 99 will change a[1]. Use copy() or append([]T{}, a[1:3]...) when you need independence.

2. append may or may not allocate - always assign the result back.

Writing append(s, x) without assigning the result is almost always a bug. The result might be a new slice with a new backing array. Always: s = append(s, x).

3. nil slice vs empty slice - both have len 0, but they are not equal.
var a []int           // nil slice:   a == nil is true
b := []int{}          // empty slice: b == nil is false
fmt.Println(len(a), len(b)) // 0 0 - same length
fmt.Println(a == nil)  // true
fmt.Println(b == nil)  // false

Both are safe to append to and range over. The nil check matters when you return a slice and callers check if s == nil to mean "nothing found." Prefer returning nil (not []T{}) for "nothing."

4. Map iteration order is randomized intentionally - do not depend on it.

Go has randomized map iteration since version 1 (on purpose, to prevent code accidentally depending on hash ordering). If you need deterministic output, sort the keys first.

5. Reading from a nil map is safe; writing to one panics.

var m map[string]int followed by m["x"] returns 0 safely. But m["x"] = 1 panics at runtime. Always use a literal or make(map[K]V) before writing.

6. Range value v is a copy each iteration - modifying it has no effect on the original slice.

If you need to modify elements, use the index form: for i := range s { s[i].Field = ... }. This becomes especially relevant with slices of structs (more in Lesson 6).

Glossary

TermMeaning
arrayA fixed-size sequence of elements with the size encoded in the type: [5]int. Rarely used directly; arrays are the foundation slices are built on.
sliceA dynamic-length view of a backing array, described by a three-field header: pointer, length, capacity. The workhorse collection in Go.
slice headerThe 24-byte struct (on 64-bit) that every slice variable actually stores: a pointer to the backing array, length, and capacity.
backing arrayThe contiguous block of memory that one or more slice headers point into. Multiple slices can share the same backing array.
length (len)The number of elements currently accessible through a slice. Returned by len(s).
capacity (cap)The total number of slots in the backing array from the slice's start position. Returned by cap(s). Determines when append must reallocate.
appendBuiltin that returns a new slice with extra elements added. May return the same backing array (if capacity allows) or a new one. Always assign the result: s = append(s, x).
makeBuiltin to allocate and initialize slices, maps, and channels. make([]int, 3, 6) creates a slice with len=3 and cap=6.
copyBuiltin that copies elements from one slice to another. Returns the number of elements copied. Creates an independent backing array.
mapAn unordered collection of key-value pairs with typed keys and values. Equivalent to Python dict, but with randomized iteration order.
key / valueThe components of a map entry. In map[string]int, the key type is string and the value type is int.
rangeKeyword for iterating over slices (gives index and value), maps (gives key and value), strings (gives byte offset and rune), and channels.
zero value (recap)The default value for any type: 0 for numeric types, "" for strings, nil for pointers/slices/maps. Missing map keys return the zero value.
nil slice / nil mapA slice or map variable declared but not initialized. Nil slices are safe to range and append to. Nil maps are safe to read but panic on write.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
array
Fixed-size sequence: [5]int. Length is part of the type. Rarely used directly.
slice
Dynamic view over an underlying array. []int. Has length (len) and capacity (cap).
map
Go's hash table. map[K]V. Iteration order is intentionally randomized.
make
Constructor for slices, maps, channels. make([]int, 5) creates a length-5 slice. make([]int, 5, 10) has cap=10.
new
Allocates zeroed memory and returns a pointer. new(int) returns *int. Used less than make.
append
Grows a slice. Returns a new slice header that may share or not share the underlying array. Always assign result: s = append(s, x).
len
Length. Works on arrays, slices, maps, strings, channels.
cap
Capacity. Underlying array size for slices. For channels, the buffer size.
Fun fact Go's map iteration order is intentionally randomized starting from Go 1.0. The team noticed programmers were writing code that relied on hash-table iteration order (which happens to be deterministic in many languages but is technically unspecified). To prevent brittle code, Go's runtime randomizes the starting iteration index on every range. The result: code that "worked" because of accidental order breaks fast. The Go team treats this as intentional pain - fail fast > silent dependence on undocumented behavior.
Bug hunt

Will this compile? If it compiles, what happens at runtime?

nil-map.go
package main

import "fmt"

func main() {
    var counts map[string]int
    counts["hello"] = 1
    fmt.Println(counts)
}
Click to reveal the bug

Compiles fine. Panics at runtime: assignment to entry in nil map.

The nil map can be READ (returns zero values, len is 0) but cannot be WRITTEN. You must initialize:

fix.go
counts := make(map[string]int)
counts["hello"] = 1

// or:
counts := map[string]int{"hello": 1}

This is one of Go's most common runtime panics in new code. Static checkers like govet catch some cases but not all.

Real-world incident Slice aliasing destroyed an audit log $750K SOX fine

A fintech audit pipeline aliased a slice without realizing it:

audit.go
recent := allTransactions[:100]
recent = anonymize(recent)         // mutates entries IN-PLACE
saveAudit(allTransactions)         // expected ORIGINAL; got anonymized

The bug: recent := allTransactions[:100] creates a slice that shares the underlying array with allTransactions. Mutating recent mutated allTransactions too. The audit log was saved with anonymized PII fields. SOX compliance violation. The team spent 3 weeks doing forensic recovery and got fined $750K.

Lesson: Slices are views, not copies. To copy defensively: dst := slices.Clone(src) (Go 1.21+), or dst := make([]T, len(src)); copy(dst, src), or dst := append([]T(nil), src...). Always copy before mutating a slice that came from somewhere else.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. len(s) on a nil slice returns:

  • Panic
  • 0
  • -1
  • nil
Show answer
Answer: b. nil slices have length 0. You can range over them (zero iterations), pass them to append, and check len. You just can't index into them.

2. What is cap() of a slice?

  • Same as len()
  • The underlying array's size from the slice header's pointer to the array's end
  • Number of buckets in the hash map
  • Max allowed size
Show answer
Answer: b. Slices are (pointer, length, capacity) triples. cap is how much room is in the underlying array starting from this slice's view. append uses cap to decide whether to grow.

3. append(s, x) always returns a new slice header. The original slice s...

  • Is unchanged
  • May or may not be modified depending on capacity
  • Is automatically updated
  • Becomes invalid
Show answer
Answer: b. If cap > len, append writes in-place and returns a slice header with len+1 pointing to the same array (so the caller's view is unchanged but the array IS mutated). If cap == len, append allocates a new larger array. Idiomatic: always assign result: s = append(s, x).

4. Map literal iteration order is...

  • Insertion order
  • Alphabetical by key
  • Hash order (consistent within a process)
  • Randomized by design - never assume order
Show answer
Answer: d. Go randomizes iteration order on every range to discourage brittle code. If you need order: keys := slices.Sorted(maps.Keys(m)) (Go 1.23+).

5. To delete a key from a map:

  • m["key"] = nil
  • delete(m, "key")
  • m["key"] = ""
  • m = remove(m, "key")
Show answer
Answer: b. delete is a built-in. Safe to call on missing keys (no-op). Cannot be called on a nil map.