Back to Notes

Go Data Structure Internals

Slices vs. Arrays: Header, len/cap, Append Aliasing

What: An array [N]T is a fixed-size value (copied on assignment). A slice is a 3-word header {ptr *T, len, cap int} viewing a backing array — copying a slice copies the header, aliasing the data.

Syntax/Signature: s := make([]T, len, cap) · full slice expression s[low:high:max] sets cap = max-low.

a := []int{1, 2, 3, 4}
b := a[:2]        // len=2 cap=4 — SAME backing array
b = append(b, 99) // fits in cap ⇒ writes a[2]
fmt.Println(a)    // [1 2 99 4]  ← spooky action

c := a[:2:2]      // cap=2: next append MUST reallocate
c = append(c, 7)  // a untouched
_ = c

How it works under the hood: append checks len < cap: if it fits, it writes in place and returns a header with len+1 — same pointer. If not, runtime.growslice allocates a new array (roughly doubling below 256 elements, ~1.25× growth above, rounded to size classes), copies, and returns a new pointer. So whether two slices alias after append depends on runtime cap — the root cause of a whole bug class. Sub-slicing never copies; a 3-byte slice of a 2GB buffer pins the 2GB (memory-leak-by-slice).

Interview angle: "What does this print?" aliasing puzzles (above) are the single most common Go screener. Second: "Why must you write s = append(s, x)?" — because the header is a value; the callee's growth is invisible otherwise.

Gotchas / common wrong answers:

  • Passing a slice to a function and expecting append inside to affect the caller — mutation of elements is visible, growth is not.
  • Believing append "always copies" or "never copies" — it's cap-dependent.
  • Arrays in Go are values: func f(a [1e6]int) copies 8MB; [3]int{1,2,3} == [3]int{1,2,3} is valid (== works on arrays, not slices).
  • Since Go 1.21/1.22: prefer slices.Clone, slices.Grow, and 1.22's slices.Concat over manual copy dances.

Trade-offs / when NOT to use: Use the 3-index form when handing out sub-slices across API boundaries to prevent callers clobbering your tail. Copy out small views of large buffers you retain.

Map Internals

What: Go 1.22 maps are open-addressed-into-buckets hash tables: an hmap header pointing at an array of buckets, each holding up to 8 key/value pairs plus a tophash byte array and an overflow pointer.

Syntax/Signature: m := make(map[K]V, sizeHint) · v, ok := m[k] · delete(m, k) · clear(m) (1.21+).

m := make(map[string]*User, 1000) // size hint avoids incremental rehashing

// Struct values in maps are NOT addressable:
type P struct{ X int }
mp := map[string]P{"a": {}}
// mp["a"].X = 1        // compile error: cannot assign
p := mp["a"]; p.X = 1; mp["a"] = p // read-modify-write
// or store pointers: map[string]*P

How it works under the hood: Hash (with a per-map random seed — hence randomized iteration order, enforced deliberately with a random start bucket) selects a bucket; the top 8 bits of the hash (tophash) are compared before full key comparison. Load factor threshold 6.5 triggers doubling; growth is incremental — old buckets are evacuated on subsequent writes, which is why elements are not addressable (they move). Deleting doesn't shrink; a map that once held 1M entries keeps its bucket array. (Go 1.24 replaced this with Swiss Tables — mention only if asked about newer versions.)

Interview angle: "Why is map iteration order random?" — hash seed + deliberate random start so nobody depends on order. "Why can't you take &m[k]?" — incremental evacuation moves entries. "Concurrent map access?" — unsynchronized read+write is a fatal throw (fatal error: concurrent map writes), not a recoverable panic; use a mutex (see [[Go Sync Primitives]]) or sync.Map for append-heavy disjoint-key workloads.

Gotchas / common wrong answers:

  • "Concurrent reads need locking" — concurrent read-only access is safe; it's read+write / write+write that's fatal.
  • Nil map: reads and len are fine, delete is a no-op, writes panic.
  • Relying on recover() to survive concurrent map access — you can't; the runtime throws, it doesn't panic.
  • Assuming delete frees memory — bucket array never shrinks (pre-1.24); reallocate the map to reclaim.

Trade-offs / when NOT to use: For tiny fixed key sets, a slice + linear scan beats a map (cache locality, no hashing). For counters across goroutines, sharded maps or atomic beat one mutex-guarded map.

Struct Memory Layout & Alignment

What: Fields are laid out in declaration order; each field is aligned to its type's alignment (up to 8 bytes on 64-bit), and the compiler inserts padding — it never reorders fields.

Syntax/Signature: unsafe.Sizeof(x), unsafe.Alignof(x), unsafe.Offsetof(x.f) — compile-time constants.

type Bad struct { // 24 bytes on amd64
	a bool  // 1 + 7 padding
	b int64 // 8
	c bool  // 1 + 7 tail padding (struct align = 8)
}

type Good struct { // 16 bytes
	b int64 // 8
	a bool  // 1
	c bool  // 1 + 6 tail padding
}

How it works under the hood: Alignment guarantees each field's offset is a multiple of its alignment; the struct's own size is rounded up to its largest field alignment so arrays of it stay aligned. Ordering fields largest-to-smallest minimizes padding. fieldalignment (in go vet's analyzer set) detects waste. Historical trap: 64-bit atomic ops on 32-bit platforms required 8-byte alignment — solved in modern code by atomic.Int64 (1.19+), which carries its own alignment.

Interview angle: "Struct is 24 bytes, fields sum to 10 — explain." Then: "When does this matter?" — arrays/slices of millions of structs (cache lines, GC scan cost), not one-off structs.

Gotchas / common wrong answers:

  • Claiming the compiler reorders fields to optimize — it doesn't (layout is part of ABI expectations and cgo/unsafe compatibility).
  • Forgetting a trailing zero-size field (struct{ x int; _ struct{} }) gets padding to prevent a past-the-end pointer.
  • Micro-optimizing field order in cold structs — readability-ordered fields are fine outside hot arrays.

Trade-offs / when NOT to use: Group fields by meaning first; pack by size only where profiles or scale justify it. Padding also prevents false sharing — sometimes you add it on purpose (_ [64]byte between hot atomics, see [[Go Sync Primitives]]).

Interview Drills

  1. Walk through exactly what append does, and when two slices alias.append(s, x): if len(s) < cap(s), write x at s[len] in the existing backing array and return a header with the same pointer and len+1 — any other slice viewing that array sees the write. If len == cap, growslice allocates a bigger array, copies, and returns a new pointer — aliasing is severed. So aliasing after append is a runtime property of cap, which is why you cap defensive sub-slices with s[a:b:c] and always reassign s = append(s, x).
  2. Why is a Go struct with fields summing to 10 bytes actually 24 bytes in memory? — Alignment padding: each field must start at an offset that's a multiple of its own alignment, and Go never reorders fields to minimize this — a bool, int64, bool layout pads after each 1-byte field to align the next one, plus tail padding so the struct's overall size is a multiple of its largest field's alignment. Reordering fields largest-to-smallest (int64, bool, bool) shrinks this to 16 bytes.
  3. Why does concurrent unsynchronized read+write access to a Go map crash the whole program instead of just producing a wrong value? — The runtime maintains an internal write-in-progress flag per map specifically to detect this and calls fatal() (an unrecoverable process-level abort, not a normal panic) rather than silently corrupting the bucket structure — this is a deliberate fail-loud design choice, not a bug, precisely because concurrent map mutation without synchronization can corrupt internal pointers in ways that are worse than a wrong answer.

Cross-links

[[Go Type Assertions & Generics]] · [[Go Sync Primitives]] · [[Go Race Conditions & Memory Model]]