Composite Types: Arrays, Slices and Maps
Composite Types: Arrays, Slices and Maps
If functions are the skeleton of a Go program, then arrays, slices, and maps are its flesh and blood. In production code, over 80% of data storage and processing relies on these three composite types. They look simple — "just arrays and hash tables, right?" — but beneath that simplicity lies precise engineering in memory layout, growth strategies, and concurrency safety.
Understanding these internals isn't academic curiosity — it directly determines whether your program responds in 10ms or crashes with OOM. This chapter starts from daily usage and progressively reveals the SliceHeader structure, append's growth algorithm, map's bucket-based hash implementation, and the pitfalls these design choices create.
Level 1: What You Need to Know
Arrays: Fixed-Length Value Types
Go's arrays differ from most languages — they are value types, not reference types. The array's length is part of its type: [3]int and [4]int are completely different types that cannot be assigned to each other.
// Declaration and initialization
var a [5]int // zero value: [0, 0, 0, 0, 0]
b := [3]int{1, 2, 3} // literal initialization
c := [...]int{1, 2, 3, 4, 5} // compiler infers length: [5]int
d := [5]int{0: 1, 4: 5} // index initialization: [1, 0, 0, 0, 5]
// Arrays are value types — assignment copies the entire array
x := [3]int{1, 2, 3}
y := x // y is a full copy of x
y[0] = 99 // modifying y doesn't affect x
fmt.Println(x[0]) // 1
Why did Go choose value semantics for arrays? This was a deliberate design decision. Ken Thompson and Rob Pike believed that reference-semantic arrays (like Java's) lead to shared-state bugs that are hard to track. Value semantics make array behavior completely predictable — passing to a function won't accidentally modify the original (Robert Griesemer, "Go Data Structures", 2009).
But this also means: passing large arrays incurs copy overhead. For large arrays, pass pointers:
// Not recommended: copies 8MB each call
func process(data [1000000]int) { ... }
// Better: pass pointer, zero copy
func process(data *[1000000]int) { ... }
// Best: use slices
func process(data []int) { ... }
Arrays have limited use cases — they're rarely used directly in practice. They mainly appear in:
- Fixed-size buffers (e.g.,
[64]byte) - Map keys (slices can't be map keys, but arrays can)
- As the underlying storage for slices
Slices: Dynamic Arrays with Reference Semantics
Slices are Go's most commonly used data structure. A slice is a view into an underlying array, providing dynamic sizing and reference semantics:
// Several ways to create slices
s1 := []int{1, 2, 3} // literal
s2 := make([]int, 5) // make(type, length)
s3 := make([]int, 3, 10) // make(type, length, capacity)
s4 := s1[1:3] // slice from existing slice/array
// The three components of a slice
fmt.Println(len(s3)) // 3 — current number of elements
fmt.Println(cap(s3)) // 10 — total capacity of underlying array
Core differences between arrays and slices:
| Property | Array | Slice |
|---|---|---|
| Length | Fixed, compile-time | Dynamic, runtime |
| Type | Length is part of type | Length is not part of type |
| Passing | Value copy | Reference semantics (passes SliceHeader) |
| Zero value | Elements at zero values | nil |
| Comparable | Can use == | Cannot use == (only compare to nil) |
Basic Slice Operations
// Appending elements
s := []int{1, 2, 3}
s = append(s, 4) // append single element
s = append(s, 5, 6, 7) // append multiple elements
s = append(s, []int{8, 9}...) // append another slice
// Deleting elements (no built-in delete function)
// Delete element at index i
s = append(s[:i], s[i+1:]...)
// Inserting elements
// Insert elem at index i
s = append(s[:i], append([]int{elem}, s[i:]...)...)
// Copying
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src) // returns number of elements copied
// Slice expressions
s = []int{0, 1, 2, 3, 4, 5}
s[2:4] // [2, 3] — from index 2 to 3 (exclusive 4)
s[:3] // [0, 1, 2] — from start to index 2
s[3:] // [3, 4, 5] — from index 3 to end
s[:] // [0, 1, 2, 3, 4, 5] — full slice
nil Slice vs Empty Slice
A subtle but important distinction:
var s1 []int // nil slice: s1 == nil, len=0, cap=0
s2 := []int{} // empty slice: s2 != nil, len=0, cap=0
s3 := make([]int, 0) // empty slice: s3 != nil, len=0, cap=0
// Behavior is identical for most operations
len(s1) == len(s2) // true, both 0
append(s1, 1) // works fine
for _, v := range s1 {} // iterates fine (0 iterations)
// Difference mainly shows in serialization
json.Marshal(s1) // null
json.Marshal(s2) // []
Best practice: If a function needs to return an empty collection, return a nil slice rather than an empty slice — nil slices don't require memory allocation. But if the result will be JSON serialized and you expect [] rather than null, use an empty slice.
Maps: Unordered Key-Value Pairs
Maps are Go's hash table implementation, providing average O(1) lookup, insertion, and deletion:
// Creation
m1 := map[string]int{"a": 1, "b": 2}
m2 := make(map[string]int) // empty map
m3 := make(map[string]int, 100) // pre-allocated capacity (performance optimization)
// CRUD operations
m1["c"] = 3 // insert/update
delete(m1, "a") // delete
value := m1["b"] // lookup (returns zero value if key doesn't exist)
value, ok := m1["b"] // lookup + existence check
// Iteration
for key, value := range m1 {
fmt.Printf("%s: %d\n", key, value)
}
Map key type requirements: Keys must be comparable. Types that can serve as keys: all basic types, pointers, arrays, structs (if all fields are comparable), interfaces. Types that cannot: slices, maps, functions.
// Legal map keys
map[int]string{}
map[[2]int]string{} // arrays work
map[struct{x,y int}]string{} // comparable structs work
// Illegal map keys
map[[]int]string{} // compile error: slices aren't comparable
map[map[int]int]string{} // compile error: maps aren't comparable
nil Map vs Empty Map
var m1 map[string]int // nil map
m2 := map[string]int{} // empty map
m3 := make(map[string]int) // empty map
// Read operations are safe on nil maps
_ = m1["key"] // returns zero value 0, no panic
_, ok := m1["key"] // ok = false
len(m1) // 0
for k, v := range m1 {} // 0 iterations
// Write operations on nil maps are NOT safe!
m1["key"] = 1 // panic: assignment to entry in nil map
Rule: nil maps are read-safe but not write-safe. This is a design choice — read operations returning zero values make code cleaner (no pre-checks needed), while write operations panic because there's no underlying storage to write to.
Common Map Usage Patterns
// Pattern 1: Set
seen := make(map[string]struct{}) // empty struct saves memory
seen["alice"] = struct{}{}
if _, exists := seen["bob"]; !exists {
fmt.Println("bob not seen")
}
// Pattern 2: Grouping
groups := make(map[string][]string)
groups["fruits"] = append(groups["fruits"], "apple")
groups["fruits"] = append(groups["fruits"], "banana")
// Pattern 3: Counting
counts := make(map[string]int)
for _, word := range words {
counts[word]++ // zero value is 0, just ++ directly
}
// Pattern 4: Caching/memoization
cache := make(map[int]int)
func fib(n int) int {
if v, ok := cache[n]; ok {
return v
}
if n <= 1 {
return n
}
cache[n] = fib(n-1) + fib(n-2)
return cache[n]
}
Level 2: How It Works Under the Hood
Slice Internals: SliceHeader
A slice in memory consists of three fields, defined in reflect.SliceHeader:
type SliceHeader struct {
Data uintptr // pointer to underlying array
Len int // current number of elements
Cap int // total capacity of underlying array
}
On 64-bit systems, SliceHeader occupies 24 bytes (8+8+8). When you pass a slice to a function, these 24 bytes are copied — the pointer, length, and capacity are duplicated, but the underlying array is not.
func modify(s []int) {
s[0] = 99 // modifies underlying array — visible to caller
s = append(s, 100) // may allocate new array — invisible to caller
}
original := []int{1, 2, 3}
modify(original)
fmt.Println(original[0]) // 99 — modification visible
fmt.Println(len(original)) // 3 — append effect invisible
This behavioral difference is critically important:
- Modifying elements by index → modifies shared underlying array → visible to caller
- append causing growth → creates new array, modifies function-local SliceHeader → invisible to caller
Slice Expressions and Shared Underlying Arrays
Slice expressions (slicing) don't create a new underlying array — old and new slices share the same memory:
original := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
sub := original[3:7] // sub = [3, 4, 5, 6]
// sub's internal structure:
// Data: points to element 3 of original's underlying array
// Len: 4 (7-3)
// Cap: 7 (10-3, from start position to end of underlying array)
sub[0] = 99
fmt.Println(original[3]) // 99 — shared underlying array!
The full three-index slice (Go 1.2+) can restrict capacity:
sub := original[3:7:7] // [low:high:max]
// Len = high - low = 4
// Cap = max - low = 4 (not 7!)
// Now append to sub will definitely allocate new memory,
// won't overwrite original's subsequent elements
sub = append(sub, 100)
fmt.Println(original[7]) // 7, not overwritten
Three-index slices are a defensive programming technique to prevent append overwrites — discussed in detail in the pitfalls section.
Append Growth Strategy
When append finds insufficient capacity, it allocates a new underlying array. The growth strategy has undergone significant changes:
Go 1.17 and earlier rules:
- New capacity < 1024: double (cap * 2)
- New capacity >= 1024: grow 25% (cap * 1.25)
Go 1.18+ new rules (Robert Griesemer, "runtime: make slice growth formula more smooth", 2021):
// Simplified logic from runtime/slice.go
func growslice(old []T, newLen int) []T {
newcap := old.cap
doublecap := newcap + newcap
if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
for newcap < newLen {
newcap += (newcap + 3*threshold) / 4
}
}
}
// Final step: round up to memory allocator's size class
}
Key changes:
- Threshold lowered from 1024 to 256
- Large slice growth formula changed from
cap * 1.25to(cap + 3*256) / 4, making growth rate smoother — close to 2x for small capacities, gradually approaching 1.25x for large ones - Final capacity is rounded up to the memory allocator's size class
Why the change? The old rule had a cliff around 1024 — abruptly shifting from 2x to 1.25x. For slices with capacity 1023 vs 1025, growth behavior was dramatically different. The new rule eliminates this discontinuity.
// Verify growth behavior
s := make([]int, 0)
prev := cap(s)
for i := 0; i < 10000; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len=%d cap=%d (growth=%.2fx)\n", len(s), cap(s), float64(cap(s))/float64(prev))
prev = cap(s)
}
}
// Go 1.18+ output shows growth rate smoothly transitioning from ~2x to ~1.25x
Performance Impact of Append
Understanding growth means understanding performance:
// No pre-allocation: each growth copies existing data + GC reclaims old array
func badPerf() []int {
var s []int
for i := 0; i < 100000; i++ {
s = append(s, i) // triggers ~27 growths
}
return s
}
// Pre-allocated: zero growths
func goodPerf() []int {
s := make([]int, 0, 100000)
for i := 0; i < 100000; i++ {
s = append(s, i) // never grows
}
return s
}
Benchmark comparison (Go 1.21, amd64):
| Method | Time | Allocations |
|---|---|---|
| No pre-allocation | ~1.2ms | 27 allocs |
| Pre-allocated | ~0.3ms | 1 alloc |
Rule: If you know (or can estimate) the final size, always use make([]T, 0, expectedSize) to pre-allocate.
Map Implementation Internals
Go's map uses a bucket-based hash table with a hybrid of open addressing and chaining. Core structure (runtime/map.go):
// hmap is the runtime representation of a map
type hmap struct {
count int // current number of elements (what len() returns)
flags uint8 // concurrent read/write detection flags
B uint8 // log2 of bucket count (buckets = 2^B)
noverflow uint16 // approximate number of overflow buckets
hash0 uint32 // hash seed (randomization)
buckets unsafe.Pointer // bucket array pointer (2^B buckets)
oldbuckets unsafe.Pointer // old bucket array during growth
nevacuate uintptr // growth progress
extra *mapextra // overflow buckets and other extras
}
// bmap is a single bucket
type bmap struct {
tophash [8]uint8 // top 8 bits of each key's hash
// followed by: 8 keys, 8 values, 1 overflow bucket pointer
// keys [8]keyType
// values [8]valueType
// overflow *bmap
}
Each bucket stores 8 key-value pairs. Lookup process:
- Compute key's hash value h
- Use low B bits of h to determine bucket number (
h & (2^B - 1)) - Use high 8 bits of h (tophash) for fast filtering within bucket
- Do full key comparison for positions where tophash matches
- If not found in current bucket, follow overflow bucket chain
Why tophash? Because full key comparison can be expensive (e.g., long strings). Tophash is an 8-bit quick pre-filter — for 8 positions in a bucket, comparing 1 byte first eliminates most non-matching positions.
Map Growth Mechanism
Maps trigger growth under these conditions:
- Load factor too high:
count / 2^B > 6.5(average more than 6.5 elements per bucket) - Too many overflow buckets: overflow bucket count exceeds regular bucket count
Two growth modes:
- Double growth (condition 1): B increments by 1, bucket count doubles
- Same-size growth (condition 2): bucket count unchanged, but data reorganized to eliminate sparse overflow buckets
Growth is incremental — not all data migrates at once. Each write operation migrates 1-2 old buckets. This prevents long pauses when growing large maps.
// State during growth:
// oldbuckets != nil means growth in progress
// Read operations: check oldbuckets first, then buckets
// Write operations: migrate corresponding old bucket first, then write
Map Hash Seed and Iteration Randomization
Go's map has two intentional randomization designs:
-
Hash seed: Each map instance generates a random
hash0at creation, used as the hash function's seed. This prevents Hash DoS attacks — attackers cannot predict which bucket a key will land in. -
Iteration start randomization: When
rangeiterates a map, both the starting bucket and starting position within the bucket are randomized.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Printf("%s:%d ", k, v)
}
// May output different order each run
// e.g.: a:1 c:3 b:2 or b:2 a:1 c:3
Why randomize iteration? The Go team intentionally does this to prevent developers from relying on map iteration order (Russ Cox, Go 1 release notes, 2012). Before Go 1, map iteration order happened to be deterministic (due to implementation details), causing much code to implicitly depend on this order. When the map implementation changed, that code would break. By introducing randomization, any code depending on iteration order exposes the problem during development.
Slice and Map GC Interaction
Slices and maps storing pointers affect GC performance. The GC must scan all objects containing pointers to determine which memory can be reclaimed.
// GC-unfriendly: each element contains pointers
type User struct {
Name string // string internally has a pointer
Age int
}
users := make([]User, 1000000)
// GC must scan 1,000,000 Users, checking each Name's pointer
// GC-friendlier: separate pointers from non-pointers
type Users struct {
Names []string
Ages []int
}
// GC only needs to scan the Names slice's pointer array
For maps:
// map[string]*BigStruct — GC must scan all values (all pointers)
// map[string]BigStruct — if BigStruct contains no pointers, GC doesn't scan values
// Optimization: if both key and value contain no pointers, map can be marked
// as not needing GC scanning. This is handled automatically by the runtime,
// but you need to be aware of type design implications.
Level 3: What the Specification Says
Slice Specification
The Go Language Specification defines slices as:
A slice is a descriptor for a contiguous segment of an underlying array and provides access to a numbered sequence of elements from that array.
SliceType = "[" "]" ElementType .
Key specification details:
Slice expressions have two forms:
// Simple form: a[low : high]
// Full form: a[low : high : max] (Go 1.2+)
The specification mandates: 0 <= low <= high <= max <= cap(a)
Violating these constraints causes a runtime panic (not a compile error, since index values may be computed at runtime).
Append specification:
If the capacity of s is not large enough to fit the additional values, append allocates a new, sufficiently large underlying array that fits both the existing slice elements and the additional values. Otherwise, append re-uses the underlying array.
The specification does not specify a concrete growth strategy — only guaranteeing "sufficiently large." This means the growth strategy is an implementation detail that may differ between versions (and Go 1.18 did change it). Do not depend on specific growth behavior.
Copy specification:
The copy built-in function copies elements from a source slice into a destination slice. The source and destination may overlap. Copy returns the number of elements copied, which will be the minimum of len(src) and len(dst).
Note "may overlap" — copy correctly handles overlapping source and destination (internally uses memmove rather than memcpy).
Map Specification
A map is an unordered group of elements of one type, called the element type, indexed by a set of unique keys of another type, called the key type.
MapType = "map" "[" KeyType "]" ElementType . KeyType = Type .
Key specification details:
Key type constraints:
The comparison operators == and != must be fully defined for operands of the key type.
This excludes slices, maps, and function types as keys. But there's a subtle point — structs containing these types also cannot be keys:
type Bad struct {
data []int // slice field
}
// map[Bad]int{} // compile error: Bad contains incomparable field
nil map specification:
A nil map is equivalent to an empty map except that no elements may be added.
The specification explicitly states the only difference between nil and empty maps is whether elements can be added. All read operations (lookup, len, range) have well-defined behavior on nil maps.
Map concurrent access:
The specification doesn't directly address concurrency safety, but the Go Memory Model document clearly states:
Maps are not safe for concurrent use: it's not defined what happens when you read and write to them simultaneously.
The runtime detects concurrent reads and writes through hmap.flags. If detected, it calls throw() directly (not panic — the program terminates immediately, unrecoverable):
fatal error: concurrent map read and map write
Arrays and Slices: Specification Distinction
Array type definition:
An array type specifies the number of elements in the array. Array types are always one-dimensional but may be composed to form multi-dimensional types.
ArrayType = "[" ArrayLength "]" ElementType .
Slice type definition:
A slice type denotes the set of all slices of arrays of its element type.
Note the wording difference: array types specify "number of elements" (length is part of the type), while slice types describe "the set of all slices of arrays" (no length involved).
For-range Semantics in the Specification
The specification's definition of range affects many behaviors:
The iteration values are assigned to the respective iteration variables as in an assignment statement.
For slices:
for i, v := range s {
// v is a COPY of s[i], not a reference
}
"Assigned... as in an assignment statement" means v is a value copy. If slice elements are large structs, each iteration copies the entire struct:
type BigStruct struct {
data [1024]byte
}
items := make([]BigStruct, 1000)
for _, item := range items {
// item is a copy of BigStruct (1024 bytes)
// copied each iteration
}
// Optimization: use index access to avoid copies
for i := range items {
doSomething(&items[i])
}
For maps:
The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next.
This is a specification-level guarantee — you absolutely cannot depend on map iteration order.
Memory Alignment with Slices/Arrays
The Go specification states:
A struct or array type has size zero if it contains no fields (or elements, respectively) that have non-zero size. Two distinct zero-size variables may have the same address in memory.
This leads to interesting behavior:
// Slices of zero-size types
type Empty struct{}
s := make([]Empty, 1000000)
fmt.Println(unsafe.Sizeof(s[0])) // 0
// The entire underlying array of s is 0 bytes!
But the SliceHeader itself still occupies 24 bytes. This is why map[T]struct{} is more memory-efficient than map[T]bool — not because struct{} takes no memory (it truly doesn't), but because the map's value portion has size 0, saving 8 bytes per bucket (bool is 1 byte but aligned to 8 bytes).
make vs new in the Specification
// make is only for slice, map, channel
s := make([]int, 5, 10) // returns initialized slice (not a pointer)
m := make(map[string]int) // returns initialized map
ch := make(chan int) // returns initialized channel
// new is for any type, returns a pointer
p := new(int) // *int, pointing to zero-value int
sp := new([]int) // *[]int, pointing to nil slice
Specification definitions:
The built-in function make takes a type T, which must be a slice, map, or channel type, optionally followed by a type-specific list of expressions. It returns a value of type T (not *T).
The built-in function new takes a type T, allocates storage for a variable of that type at run time, and returns a value of type *T pointing to it.
Key difference: make returns an initialized value (ready to use), while new returns a pointer to a zero value. For maps, new(map[string]int) returns a pointer to a nil map — writing to it panics.
Level 4: Edge Cases and Pitfalls
Pitfall: Slice Append Overwrite
This is one of Go's most insidious bugs:
original := []int{1, 2, 3, 4, 5}
sub := original[1:3] // [2, 3], len=2, cap=4
// sub's cap is 4, append won't trigger growth
sub = append(sub, 99)
fmt.Println(original) // [1, 2, 3, 99, 5] — original[3] was overwritten!
Root cause: sub and original share the underlying array. sub's capacity is sufficient for the new element (cap=4, len=2), so append writes directly to the underlying array's 4th position — which happens to be original[3].
Fixes:
Fix 1: Use three-index slice to restrict capacity
sub := original[1:3:3] // cap = 3-1 = 2, same as len
sub = append(sub, 99) // cap insufficient, allocates new array
fmt.Println(original) // [1, 2, 3, 4, 5] — safe
Fix 2: Use copy to create an independent slice
sub := make([]int, 2)
copy(sub, original[1:3])
sub = append(sub, 99) // operates on its own underlying array
Real-world production impact:
// A real bug in an HTTP handler
func handleRequest(baseHeaders []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// BUG: all requests share baseHeaders' underlying array
headers := append(baseHeaders, "X-Request-Id: "+requestID(r))
// if baseHeaders' cap > len, subsequent requests overwrite previous headers
setHeaders(w, headers)
}
}
// Fix
func handleRequest(baseHeaders []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
headers := make([]string, len(baseHeaders), len(baseHeaders)+1)
copy(headers, baseHeaders)
headers = append(headers, "X-Request-Id: "+requestID(r))
setHeaders(w, headers)
}
}
Pitfall: Concurrent Map Write Panic
m := make(map[int]int)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m[n] = n * n // concurrent writes
}(i)
}
wg.Wait()
// fatal error: concurrent map writes
// This is a fatal error, not a panic — cannot recover
Why fatal error instead of panic? The Go team's design decision: concurrent map operations indicate a data race, and data races lead to undefined behavior. If panic + recover let the program continue, it might execute on corrupted data, causing even bigger problems. Immediate termination is the safest choice.
Solutions:
Solution 1: Use sync.Mutex
type SafeMap struct {
mu sync.RWMutex
m map[int]int
}
func (sm *SafeMap) Set(k, v int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[k] = v
}
func (sm *SafeMap) Get(k int) (int, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
v, ok := sm.m[k]
return v, ok
}
Solution 2: Use sync.Map (better performance in specific scenarios)
var m sync.Map
m.Store("key", "value")
v, ok := m.Load("key")
m.Delete("key")
m.Range(func(k, v interface{}) bool {
fmt.Println(k, v)
return true // return false to stop iteration
})
sync.Map is appropriate for (per its documentation):
- Keys rarely change (read-only or write frequency far lower than reads) — good for caches
- Multiple goroutines read/write non-overlapping key sets — good for sharded processing
In other scenarios (frequent writes to overlapping keys), sync.Mutex + regular map is usually faster.
Pitfall: Range Loop Value Copy
type Player struct {
Name string
Score int
}
players := []Player{
{"Alice", 100},
{"Bob", 200},
}
// BUG: v is a copy, modifying v doesn't affect the original slice
for _, v := range players {
v.Score += 10 // modifies a copy, ineffective
}
fmt.Println(players[0].Score) // 100, unchanged
// Fix 1: use index
for i := range players {
players[i].Score += 10
}
// Fix 2: use pointer slice
pplayers := []*Player{
{"Alice", 100},
{"Bob", 200},
}
for _, v := range pplayers {
v.Score += 10 // v is a pointer copy, but points to same Player
}
Pitfall: Deleting Map Elements During Iteration
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4}
// Specification allows deleting elements during range
for k, v := range m {
if v%2 == 0 {
delete(m, k) // safe: delete current or other keys
}
}
// m now only has keys with odd values
// But adding elements during range has undefined behavior
for k := range m {
m[k+"_copy"] = m[k] // newly added keys may or may not be iterated
}
The Go specification explicitly states:
If a map entry that has not yet been reached is removed during iteration, the corresponding iteration value will not be produced. If a map entry is created during iteration, that entry may be produced during the iteration or may be skipped.
Pitfall: Slice Memory Leaks
// Memory leak scenario
func getFirstToken(data []byte) []byte {
// data might be 1GB of file content
idx := bytes.IndexByte(data, ' ')
return data[:idx] // returned small slice holds reference to entire 1GB array
}
// Fix: copy the needed data
func getFirstToken(data []byte) []byte {
idx := bytes.IndexByte(data, ' ')
token := make([]byte, idx)
copy(token, data[:idx])
return token // new underlying array is only idx bytes
}
This is a side effect of slice reference semantics: as long as any slice references any part of the underlying array, the entire underlying array won't be GC'd.
The same problem appears in append's "re-slicing":
// Removing an element from the middle of a slice
func removeIndex(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
// Note: memory at s[len(s)-1] still holds old value
// If element type contains pointers, old value won't be GC'd (memory leak)
}
// Fix: zero out the tail element
func removeIndex(s []int, i int) []int {
copy(s[i:], s[i+1:])
s[len(s)-1] = 0 // clear reference to old object
return s[:len(s)-1]
}
Interview Question: What Does This Code Output?
func main() {
s := []int{1, 2, 3}
for i, v := range s {
if i == 0 {
s = append(s, 4)
}
fmt.Print(v, " ")
}
}
Answer: 1 2 3
Explanation: range determines the iteration length at the start (based on initial len(s) = 3). Even though s is modified during the loop (element 4 added), range still only iterates the original 3 elements. But note — if you modify existing elements during the loop (via index), modifications are visible:
s := []int{1, 2, 3}
for i, v := range s {
if i == 0 {
s[2] = 99 // modify existing element
}
fmt.Print(v, " ")
}
// Output: 1 2 99
// Because the s used by range and the external s share underlying array (when no growth occurs)
But if the append in the first iteration triggers growth (insufficient cap), the situation differs — range uses the old underlying array. This behavioral complexity is precisely why modifying slices being iterated in range loops is discouraged.
Interview Question: Map Values Are Not Addressable
type Config struct {
Debug bool
}
configs := map[string]Config{
"app": {Debug: false},
}
// Compile error: cannot assign to struct field configs["app"].Debug in map
configs["app"].Debug = true
// Why? Because map values are not addressable
// Maps may reallocate at any time during growth, addresses are unstable
Fixes:
// Fix 1: replace entire value
c := configs["app"]
c.Debug = true
configs["app"] = c
// Fix 2: use pointer values
configs := map[string]*Config{
"app": {Debug: false},
}
configs["app"].Debug = true // works, because we get a copy of the pointer
Performance Optimization: Impact of Pre-allocation
// Benchmark comparison
func BenchmarkMapNoPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int)
for j := 0; j < 10000; j++ {
m[j] = j
}
}
}
func BenchmarkMapPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int, 10000)
for j := 0; j < 10000; j++ {
m[j] = j
}
}
}
Typical results:
| Method | Time | Allocations |
|---|---|---|
| No pre-allocation | ~800us | ~20 allocs |
| Pre-allocated | ~400us | ~2 allocs |
Pre-allocating maps avoids multiple rehashes and data migrations during growth, improving performance by approximately 50%.
Real-world Case: Slice Pitfall in Kubernetes
Kubernetes source code had a classic bug (kubernetes/kubernetes#81745):
// Simplified version
func filterPods(pods []Pod, condition func(Pod) bool) []Pod {
filtered := pods[:0] // reuse underlying array
for _, p := range pods {
if condition(p) {
filtered = append(filtered, p)
}
}
return filtered
}
Problem: pods[:0] creates a slice with len=0 sharing the underlying array. Appending to filtered overwrites data in pods. If the caller subsequently uses the original pods slice, they'll see modified data.
This "in-place filter" pattern is only safe when the caller explicitly knows and accepts that the underlying array will be modified.
Tool: Race Detector for Concurrent Map Detection
go run -race main.go
go test -race ./...
The race detector inserts memory access detection code at compile time, precisely reporting data race locations at runtime:
==================
WARNING: DATA RACE
Write at 0x00c000090000 by goroutine 7:
runtime.mapassign_fast64()
/usr/local/go/src/runtime/map_fast64.go:92 +0x0
main.main.func1()
/tmp/main.go:15 +0x44
Previous write at 0x00c000090000 by goroutine 6:
runtime.mapassign_fast64()
/usr/local/go/src/runtime/map_fast64.go:92 +0x0
main.main.func1()
/tmp/main.go:15 +0x44
==================
Recommendation: Always enable -race testing in CI. Race detector runtime overhead is approximately 5-10x and memory overhead approximately 5-10x — suitable for testing but not production.
This chapter deeply analyzed the design and implementation of Go's three major composite types. The SliceHeader + shared underlying array mechanism explains the vast majority of "unexpected" behaviors; the bucket hash + incremental growth of maps explains their performance characteristics and concurrency limitations. With these underlying models understood, your Go code will no longer produce the confusion of "why did data mysteriously change here" or "why did we suddenly OOM."
The next chapter enters the world of object orientation — structs, methods, and interfaces — how Go's philosophy of composition over inheritance manifests in its type system.