The subtle data bugs that compile, pass tests, and survive review — because the Python instinct is almost right.
[]T or a map[K]V in a diff and instantly know what is shared and what is copied.
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]
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.
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]
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]
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.
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.
zeliboba/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.
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]
append elsewhere can. The append(nil, src...) or slices.Clone(src) defensive copy is the fix.
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.
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
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
}
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".
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]
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.
append discarded — append(s, x) as a statement, not s = append(s, x). The grown slice is lost.append can alias it. Want slices.Clone / append(nil, s...).s[i:j] then append — may overwrite s's later elements (shared backing array). Three-index s[i:j:j] caps capacity to force a fresh array on the next grow.make'd, or a returned map that callers write to.range order — building ordered output, or a test asserting iteration sequence.Instant feedback. Reps, not grades.
func f(xs []int) {
xs[0] = 99
xs = append(xs, 7)
}
a := []int{1, 2, 3}
f(a)What is a after the call?var counts map[string]int
n := counts["a"]
counts["a"] = n + 1What happens?func (c *Cache) Set(key string, vals []int) {
c.data[key] = vals // store caller's slice directly
}A reviewer should flag this because…map[string]string and adds a test asserting the exact output. Concern?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.
Added to your GLOSSARY.md:
ptr, len, cap) copied on assignment/pass; the backing array it points to is shared.var s []T); a usable empty slice — len 0, ranges over nothing, appendable.make'd before writing.len = elements in use, cap = room before a reallocation; make([]T, len, cap).