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
appendinside 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'sslices.Concatover 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
lenare fine,deleteis a no-op, writes panic. - Relying on
recover()to survive concurrent map access — you can't; the runtime throws, it doesn't panic. - Assuming
deletefrees 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
- Walk through exactly what
appenddoes, and when two slices alias. —append(s, x): iflen(s) < cap(s), writexats[len]in the existing backing array and return a header with the same pointer andlen+1— any other slice viewing that array sees the write. Iflen == cap,growsliceallocates 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 withs[a:b:c]and always reassigns = append(s, x). - 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, boollayout 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. - 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 normalpanic) 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]]