Slices, Maps & Strings
Slice headers, append aliasing, map internals, runes and strings.Builder
- 01
Your search endpoint answers
{"items": null}when nothing matches and the mobile client crashes on it. The handler starts withvar results []Productand never appends. How does that differ fromresults := []Product{}?EasyA nil slice and an empty slice behave identically in Go and differently on the wire:
var results []Productmarshals tonull,[]Product{}marshals to[].var a []Product // nil header: ptr nil, len 0, cap 0 b := []Product{} // non-nil ptr, len 0, cap 0 fmt.Println(a == nil, b == nil, len(a) == len(b)) // true false true a = append(a, p) // append allocates for a nil slice: no initialisation needed … - 02
A validator rejects the username
Дмитрийas 14 characters long, andname[0]prints208instead of a letter. What islencounting?EasyA Go string is an immutable slice of bytes holding UTF-8, so
lenis a byte count ands[i]is a singlebyte—Дмитрийis 7 characters stored in 14 bytes.name := "Дмитрий" fmt.Println(len(name)) // 14 — bytes fmt.Println(utf8.RuneCountInString(name)) // 7 — code points fmt.Println(name[0]) // 208 — one byte, not "Д" … - 03
if orders[userID] == 0 { return ErrNoSuchUser }reports a missing user for someone who genuinely has zero orders, and a lookup for a key nobody ever inserted returns 0 rather than failing. How do you ask a map whether a key is there?EasyIndexing a map always succeeds and returns the value type's zero value, so presence is a separate question answered by the comma-ok form
v, ok := m[k].orders := map[string]int{"ada": 0} // ada exists and has zero orders fmt.Println(orders["grace"]) // 0 — missing key, no panic n, ok := orders["ada"] fmt.Println(n, ok) // 0 true — stored zero, not absent … - 04
Passing a
[32]byteto a helper that overwrites it leaves the caller's copy untouched; change the parameter to[]byteand the caller's data really does change. What is different about the two types?EasyAn array is a value whose length is part of its type, so passing one copies every byte; a slice is a three-word header — pointer, length, capacity — so passing one copies the header and shares the array it points at.
func zeroArray(a [4]byte) { a[0] = 0 } // operates on a copy func zeroSlice(s []byte) { s[0] = 0 } // operates on the caller's array arr := [4]byte{1, 2, 3, 4} zeroArray(arr) fmt.Println(arr) // [1 2 3 4] — untouched … - 05
func addDefaults(tags []string)appends two entries and the caller still sees its original three. A colleague changes the parameter to*[]stringand it "works". What doesappenddo to the slice you handed over?MediumThe callee receives a copy of the three-word header, so
appendupdates that copy's length and the caller's header never moves — which is whyappendreturns a slice and its result must be assigned.// ❌ appends into a copy of the header; the caller sees nothing func addDefaults(tags []string) { tags = append(tags, "env:prod", "team:core") } // ✅ take and return, the way append itself is shaped … - 06
A converter starts with
out := make([]Row, len(in)), appends inside the loop, and returns twice as many rows as it was given — the first half of them zeroed. What ismake's second argument?Mediummake([]T, n)builds a slice already containingnzero values, andappendadds after them; preallocation without the zeros ismake([]T, 0, n).in := []string{"a", "b", "c"} // ❌ three zero Rows first, then the three real ones out := make([]Row, len(in)) for _, v := range in { out = append(out, Row{Key: v}) … - 07
Two goroutines get their arguments from one base slice —
x := append(base, "x")and theny := append(base, "y")— andxcomes out holding"y". What do the two results share?MediumBoth appends wrote into the same spare capacity of
base's backing array, so the second overwrote the first: a slice expression never copies, it points a new header at memory that somebody else still owns.base := make([]string, 0, 4) base = append(base, "a", "b") // len 2, cap 4 x := append(base, "x") y := append(base, "y") // writes the same slot as x did fmt.Println(x, y, &x[0] == &y[0]) // [a b y] [a b y] true … - 08
Each request reads a 4 MB body and keeps only
body[:64]from it as a fingerprint, yet memory climbs all day. What is retained is 64 bytes. Where did the megabytes go?MediumA subslice keeps the entire backing array alive: the garbage collector frees an array only when no slice header points anywhere into it, so 64 bytes of interest pin all 4 MB.
// ❌ fingerprint is 64 bytes and keeps 4 MB reachable func (c *Cache) note(body []byte) { c.prints = append(c.prints, body[:64]) } // ✅ copy out what you keep; the body can be collected … - 09
Rendering a settings map straight into the response body passes locally and fails one CI run in five against the golden file. What is not stable?
MediumMap iteration order is randomised by design — the runtime picks a fresh starting point for every
rangestatement, so the order is not reproducible between runs, between statements, or even between two loops over the same untouched map.m := map[string]int{"a": 1, "b": 2, "c": 3} // ❌ a different byte sequence on every run var b strings.Builder for k, v := range m { fmt.Fprintf(&b, "%s=%d;", k, v) … - 10
stats[key].Count++refuses to compile withcannot assign to struct field stats[key].Count in map, whilerows[i].Count++over a slice of the same struct is fine. What is different about a map element?MediumA map element is not addressable: the table may move entries as it grows, so the language refuses to hand out a pointer into it — a slice element lives in a backing array that never moves under you, so it is addressable and assignable in place.
type Stat struct{ Count int } stats := map[string]Stat{} stats["a"].Count++ // ❌ cannot assign to struct field stats["a"].Count in map p := &stats["a"] // ❌ cannot take the address of stats["a"] … - 11
One cleanup loop deletes expired sessions while ranging the map; a second re-inserts renewed ones in the same loop and returns a different count on every run. A reviewer calls both undefined. Which one actually is?
MediumDeleting during a
rangeis explicitly safe; inserting is not — the spec says an entry removed during iteration will not be produced, while an entry created during iteration may be produced or may be skipped.// ✅ defined by the spec: a deleted entry will not be produced for k, s := range sessions { if s.ExpiresAt.Before(now) { delete(sessions, k) } } … - 12
Assembling a 100 KB report with
s += linein a loop takes 40 ms and allocates half a gigabyte for a 100 KB result. Where do the other 530 MB come from?MediumStrings are immutable, so
s += lineallocates a new string and copies both operands every time — n appends copy O(n^2) bytes in total;strings.Builderappends into one growing buffer and copies nothing at the end.// ❌ 41 ms, 531 MB allocated, 10051 allocations for a 100 KB result s := "" for _, line := range lines { s += line } … - 13
A cache built on a plain map survives a week of staging and then takes the whole process down in production with
fatal error: concurrent map writes— and therecover()in the HTTP middleware never runs. Why is it not a panic?HardIt is a runtime
throw, not a panic: the map implementation notices two goroutines inside it at once and kills the process on purpose, because a half-written hash table cannot be unwound safely.// ❌ two requests writing the same map is a coin flip away from // "fatal error: concurrent map writes" — no recover() will save it type Cache struct { entries map[string]Entry } … - 14
Emptying a two-million-entry lookup map —
deletefor every key, thenclear(m)— leaves the heap exactly where it was.len(m)is 0. What is still allocated?HardA Go map never shrinks.
deleteandclearempty the slots but keep every bucket the table ever grew, so the only way to give the memory back is to drop the map itself and let the GC take the whole thing.func heapMB() uint64 { runtime.GC() var ms runtime.MemStats runtime.ReadMemStats(&ms) return ms.HeapInuse >> 20 } … - 15
An alloc profile of a hot parser is topped by
runtime.slicebytetostring, yet the identicalstring(b)used as a map key two lines above never appears in it. Which conversions actually allocate?HardA
[]bytetostringconversion allocates and copies, because the result must be immutable — unless the compiler can prove the string does not outlive the expression, which it can for map lookups, comparisons and a few other shapes.var header = []byte("this-is-a-fairly-long-header-value-over-32-bytes") var sinkS string var sinkB []byte // measured with testing.AllocsPerRun: … - 16
sessions = append(sessions[:i], sessions[i+1:]...)removes the right element, but a heap profile still shows the removed*Sessionreachable and another slice over the same array now reports the last element twice. What did the append do?HardThe append shifted the tail down inside the same backing array: the removed pointer is still sitting in the now-unused final slot, and every other header over that array sees the shifted contents.
// ❌ shifts in place, leaves the last pointer live past len(s) sessions = append(sessions[:i], sessions[i+1:]...) fmt.Println(sessions[:len(sessions)+1]) // the removed *Session, still reachable // ✅ same shift, vacated slots zeroed sessions = slices.Delete(sessions, i, i+1) …