Go for Reviewers · Lesson 07

Slices & Maps — the Reference Gotcha

The subtle data bugs that compile, pass tests, and survive review — because the Python instinct is almost right.

Why this, now: A Go slice is passed by value, yet mutating one of its elements is visible to the caller. A nil map reads fine but panics on write. Both facts contradict the Python model just enough to slip past a reviewer. Your job this lesson: see a []T or a map[K]V in a diff and instantly know what is shared and what is copied.

1 · A slice is a 3-word header over a shared array

This is the one mental model the whole lesson rests on. A Go slice value is not the data — it's a tiny struct: a pointer to a backing array, a length, and a capacity. [Go blog: slices]

ptr →len 3cap 5 points at → abc__
The header (left) is what gets copied when you pass or assign a slice. The backing array (right) is shared.

Python's list has no such split: a name is always a reference to the one list object. Go copies the header by value — but since the header still points at the same array, an element write reaches through.

Python · always a reference
def f(xs):
    xs[0] = 99      # caller sees it
    xs.append(7)    # caller sees it too

a = [1, 2, 3]
f(a)              # a == [99, 2, 3, 7]
Go · header copied, array shared
func f(xs []int) {
    xs[0] = 99           // caller SEES it
    xs = append(xs, 7)  // caller does NOT
}
a := []int{1, 2, 3}
f(a)                      // a == [99, 2, 3]
Where the analogy breaks: element write (xs[0]=99) reaches the caller in both languages — the array is shared. But append reassigns the local copy of the header (new len, maybe a new array): in Python the list grows for everyone; in Go the caller's a still has len 3 and never sees the 7. Same line, opposite outcome. That's why Go functions that grow a slice return it (xs = append(xs, …)) — there's no other way to hand the new header back.

2 · The append loop — the pattern you'll read constantly

From zeliboba/zemongo/repository.go:65 — decode every cursor row into a result slice.

var models []M                          // nil slice: ptr=nil, len=0, cap=0
for cursor.Next(ctx) {
    model := M(new(T))
    if err := cursor.Decode(model); err != nil {
        return nil, zekit.Error.WrapWithNoMessage(err)
    }
    models = append(models, model)    // grow; reassign the header each time
}

Two things a reviewer reads off this instantly. First, var models []M is a nil slice, not an empty-but-allocated one — and that is fine: append to nil allocates on first use, len(nil)==0, and ranging over nil does nothing. A nil slice is a usable empty slice. Second, models = append(...) reassigns on every iteration because it must (§1) — if you ever see append(models, x) with the result thrown away, that's a bug.

Pre-sizing when the count is knownzeliboba/zerequest/logging.go:90 writes fields := make([]zap.Field, 0, 8): length 0, capacity 8. Same nil-vs-empty behavior, but the backing array is pre-allocated so the first 8 appends don't reallocate. Read make([]T, 0, n) as "empty, but room for n". Contrast make([]T, n)length n, n zero values you index into directly.

3 · The aliasing hazard — and the repo's defense against it

Because append may reuse the existing backing array (when there's spare capacity), two slices can silently share storage. A sub-slice s[i:j] is the classic trap: it points into s's array. Append to the sub-slice while there's capacity, and you overwrite s's later elements. This survives review because it only bites when capacity happens to allow it.

From zeliboba/zemessage/consumer.go:526 — note how the house code avoids the trap.

if n == len(dependents) {
    c.consumerStates[cid].Dependents = nil
} else {
    c.consumerStates[cid].Dependents =
        append(([]*ElasticConsumerDependent)(nil), dependents[n:]...)
}

Read the right-hand side: append(nil, dependents[n:]...) appends the tail of dependents onto a fresh nil slice. The result gets its own brand-new backing array — it does not alias dependents. A naive dependents[n:] alone would keep pointing into the original array (and keep the whole array alive in memory, even the dropped prefix). This is the idiomatic "copy a slice so the caller can't mutate my array, and I don't pin theirs." [SliceTricks: copy]

Reviewer flag: when a function stores a slice it received as a parameter (saves it to a struct field, a map, a cache), ask: does it copy first? If it stashes the caller's slice directly, the caller can later mutate the stored data out from under it — or an append elsewhere can. The append(nil, src...) or slices.Clone(src) defensive copy is the fix.

4 · Maps — nil reads, but nil writes panic

A map variable's zero value is nil. Reading a nil map is safe (you get the value type's zero value); writing to a nil map is a runtime panic. Python has no equivalent — a dict is either an object or None, and indexing None raises a clear TypeError, not a "this looked like a map" panic.

Go · nil map
var m map[string]int   // nil
v := m["x"]            // OK → 0
_, ok := m["x"]       // OK → 0,false
m["x"] = 1            // panic: assignment
                       // to entry in nil map
Go · initialized map
m := make(map[string]int)
m["x"] = 1            // OK
// or with capacity hint:
m2 := make(map[string]int, len(src))

From zeliboba/zemongo/factory.go:20 — the safe pattern: map field, make'd in the constructor, written under a mutex.

type Factory struct {
    collections map[string]*mongo.Collection
    mutex       sync.Mutex
}
func newFactory(...) *Factory {
    return &Factory{
        collections: make(map[string]*mongo.Collection),  // ← without this, first write panics
    }
}
func (f *Factory) GetCollection(...) (...) {
    f.mutex.Lock(); defer f.mutex.Unlock()
    ...
    f.collections[name] = collection   // safe: map is non-nil, write is guarded
}
Two map reflexes for review: (1) A struct with a map field — find where it's make'd. If a constructor doesn't init it and a method writes to it, that's a latent panic. (2) Maps are not safe for concurrent use. A map written by one goroutine and read by another with no mutex is a data race ([[data-race]], L6) — here the sync.Mutex guards it. Concurrent writes can even crash the whole program with a fatal "concurrent map writes".

5 · Map iteration order is randomized — on purpose

From zeliboba/zemongo/client.go:64.

collections := make(map[string]*mongo.Collection, len(config.Collections))
for id, cc := range config.Collections {   // order is NOT insertion, NOT sorted
    collections[id], err = newCollection(ctx, connection, cc)
    ...
}

Go deliberately randomizes map iteration order — each range over the same map can visit keys in a different sequence. Here it's harmless (each write is independent). But Python 3.7+ guarantees dict preserves insertion order, so a Python engineer's instinct — "I'll just range the map to build an ordered output" — is a real bug in Go. [Go blog: map iteration order]

Where the analogy breaks: never rely on the order a range over a map produces. If output order matters, the idiom is: collect keys into a slice, sort the slice, then range the slice and look up the map. A PR that builds a list or a serialized payload by ranging a map directly — and asserts on its order in a test — will flake. Flag it.

6 · The reference gotcha — what to flag

🚨 Subtle data bugs that compile and pass tests. Train your nose for:

7 · Drill — read the reference semantics

Instant feedback. Reps, not grades.

Q1 What does the caller see?
func f(xs []int) {
    xs[0] = 99
    xs = append(xs, 7)
}
a := []int{1, 2, 3}
f(a)
What is a after the call?
Q2 nil map
var counts map[string]int
n := counts["a"]
counts["a"] = n + 1
What happens?
Q3 Stored slice
func (c *Cache) Set(key string, vals []int) {
    c.data[key] = vals   // store caller's slice directly
}
A reviewer should flag this because…
Q4 Map order
A PR builds a comma-separated string by ranging a map[string]string and adds a test asserting the exact output. Concern?
I'm your teacher — ask me anything.
Want to go hands-on with the three-index slice s[i:j:k] and exactly when the backing array is reused vs. reallocated? Or the shadowing hunt (:= re-declaring a variable in an inner scope — a spot-the-bug drill teased back in L3)? Or the deeper context.Context propagation pass offered at the end of L6? Say the word.

Terms introduced

Added to your GLOSSARY.md: