08 — Maps
Hash Map Internals
go
// Go's map is a hash table with overflow buckets.
// ┌──────────────────────────────────────────────────────────────────┐
// │ hmap struct (runtime/map.go) │
// │ ├── count int — number of entries │
// │ ├── B uint8 — log2 of bucket count (2^B buckets) │
// │ ├── hash0 uint32 — hash seed (randomized per map) │
// │ ├── buckets unsafe.Pointer — array of 2^B buckets │
// │ └── oldbuckets unsafe.Pointer — pre-growth buckets (for evac) │
// │ │
// │ Bucket (8 entries max): │
// │ ├── tophash [8]uint8 — top byte of each key's hash │
// │ ├── keys [8]KeyType │
// │ ├── values [8]ValueType │
// │ └── overflow *bucket — linked list for >8 collisions │
// │ │
// │ Lookup: hash(key) → bucket = hash & (2^B - 1) │
// │ scan tophash array for top byte match → full key compare│
// │ if bucket full, follow overflow pointer │
// │ │
// │ Growth: when load factor > 6.5 or too many overflow buckets: │
// │ allocate 2x buckets, evacuate incrementally (not all │
// │ at once — amortized, prevents latency spikes) │
// └──────────────────────────────────────────────────────────────────┘
func internals() {
m := make(map[string]int, 1000) // hint: pre-allocate ~1000 buckets
// The hint is NOT a limit — the map grows as needed.
// But pre-sizing avoids the incremental growth phase (rehashing).
// Actual bucket count = next power of 2 >= hint.
// Each bucket holds 8 entries. With 1000 entries and 8 per bucket:
// 128 buckets minimum (2^7 = 128). The hint causes B=7.
// Without the hint, the map starts with B=0 (1 bucket) and grows
// incrementally, rehashing multiple times to reach B=7.
}
Declaration and the Comma-OK Idiom
go
// ┌────────────────────────────────────────────────────────────┐
// │ Declaration │ Nil? │ Can Write? │
// │ ──────────────────────── │ ─────── │ ────────────────────── │
// │ var m map[K]V │ yes │ ❌ panic on write │
// │ m = map[K]V{} │ no │ ✅ │
// │ m = make(map[K]V) │ no │ ✅ │
// │ m = make(map[K]V, n) │ no │ ✅ (pre-sized) │
// └────────────────────────────────────────────────────────────┘
func basics() {
m := map[string]int{"a": 1, "b": 2}
m["c"] = 3
delete(m, "a")
// Comma-ok: distinguish "absent" from "zero value"
v, ok := m["x"]
if !ok {
// "x" is not in the map
fmt.Println("missing")
} else {
fmt.Println("value:", v)
}
// ⚠️ Without comma-ok, m["missing"] returns the ZERO VALUE:
fmt.Println(m["nonexistent"]) // 0 — indistinguishable from a stored 0
// Reading a nil map is safe (returns zero value):
var nilMap map[string]int
fmt.Println(nilMap["a"]) // 0 — no panic
// Writing to a nil map PANICS:
// nilMap["a"] = 1 // panic: assignment to entry in nil map
}
The Non-Addressable Value Trap
go
type User struct{ Name string; Active bool }
func mapFieldTrap() {
users := map[int]User{1: {"Alice", false}}
// ❌ Compile error: cannot assign to struct field users[1].Active
// users[1].Active = true
//
// WHY: map entries are NOT addressable — they can move during
// rehashing. &users[1] would be a dangling pointer after a growth.
// ✅ Fix 1: copy out, modify, assign back:
u := users[1]
u.Active = true
users[1] = u
// ✅ Fix 2: store pointers (the struct lives on the heap, the map
// holds pointers — pointers are addressable):
usersPtr := map[int]*User{1: {"Alice", false}}
usersPtr[1].Active = true // ✅ usersPtr[1] is *User, modifiable
// Trade-off: extra allocation per struct, must nil-check pointers
}
// ✅ Production pattern: map of pointers for mutable structs:
type Session struct {
ID string
UserID int64
Expiry time.Time
}
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session // pointers — fields modifiable in place
}
func (s *SessionStore) Touch(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[id]; ok { // nil-check the pointer
sess.Expiry = time.Now().Add(30 * time.Minute)
}
}
Concurrent Map Access — The Fatal Error
go
// ❌ ANTI-PATTERN: concurrent map writes — FATAL (not recoverable)
func concurrentMapBad() {
m := map[int]int{}
go func() { m[1] = 1 }()
go func() { m[2] = 2 }()
// fatal error: concurrent map writes
// The runtime detects this and aborts — you CANNOT recover.
// This is not a panic; it's a direct crash.
time.Sleep(time.Second)
}
// ✅ Pattern 1: sync.RWMutex + map (general purpose, type-safe)
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func NewSafeMap[K comparable, V any]() *SafeMap[K, V] {
return &SafeMap[K, V]{m: make(map[K]V)}
}
func (sm *SafeMap[K, V]) Get(key K) (V, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
v, ok := sm.m[key]
return v, ok
}
func (sm *SafeMap[K, V]) Set(key K, val V) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[key] = val
}
func (sm *SafeMap[K, V]) Delete(key K) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.m, key)
}
// ✅ Pattern 2: sync.Map (write-rarely, read-many)
// import "sync"
var cache sync.Map
cache.Store("key", "value")
v, ok := cache.Load("key")
cache.Delete("key")
cache.Range(func(k, v any) bool {
return true // continue iteration
})
// Trade-off: uses `any` (no generics — must type-assert), slower than
// map+RWMutex for balanced read/write, but faster for write-rarely.
// ✅ Pattern 3: channel-owned map (owner goroutine pattern)
// See chapter 26 — one goroutine owns the map, others send commands.
Ordered Map — Production Implementation
go
// Go's map is unordered (intentionally randomized). When you need
// insertion-order iteration, build an ordered map:
type OrderedMap[K comparable, V any] struct {
keys []K // insertion order
data map[K]V // fast lookup
}
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
return &OrderedMap[K, V]{data: make(map[K]V)}
}
func (m *OrderedMap[K, V]) Set(key K, val V) {
if _, exists := m.data[key]; !exists {
m.keys = append(m.keys, key) // only add to keys on first insertion
}
m.data[key] = val
}
func (m *OrderedMap[K, V]) Get(key K) (V, bool) {
v, ok := m.data[key]
return v, ok
}
func (m *OrderedMap[K, V]) Delete(key K) {
if _, exists := m.data[key]; !exists {
return
}
delete(m.data, key)
// Remove from keys slice (O(n) — use a linked list for O(1) deletion):
for i, k := range m.keys {
if k == key {
m.keys = append(m.keys[:i], m.keys[i+1:]...)
break
}
}
}
func (m *OrderedMap[K, V]) Range(fn func(K, V) bool) {
for _, k := range m.keys {
if !fn(k, m.data[k]) {
break
}
}
}
Key Types — What Can Be a Map Key
go
// Keys must be COMPARABLE (support == and !=):
// ✅ bool, int, float, string, rune, pointer, channel, interface
// ✅ array (if element type is comparable)
// ✅ struct (if all fields are comparable)
// ❌ slice, map, function — NOT comparable (can't be keys)
// Struct key — compound key (cleaner than nested maps):
type Point struct{ X, Y int }
grid := map[Point]string{
{0, 0}: "origin",
{1, 2}: "target",
}
// ❌ Nested map is slower (two hash lookups) and less clear:
// grid := map[int]map[int]string{} // grid[1][2] — two lookups
// Array key — fixed-size byte sequence as key:
paths := map[[32]byte]*Node{} // SHA-256 hash → node
// For a "string key" that's a byte slice — convert to string:
// m[string(byteSlice)] = value
// ⚠️ This allocates (string(bytes) copies). For high-frequency keys,
// use a string from the start (avoid the conversion).
Iteration — Randomized Order and Safe Deletion
go
func iteration() {
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4}
// Iteration order is RANDOMIZED (intentionally — prevents code
// from depending on order, which would break on different Go versions):
for k, v := range m {
fmt.Println(k, v) // order varies each run
}
// Ordered iteration — extract and sort keys:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k]) // deterministic: a, b, c, d
}
// Safe deletion during iteration:
for k := range m {
delete(m, k) // ✅ safe — Go explicitly allows this
}
fmt.Println(len(m)) // 0
// ⚠️ Adding keys during iteration is UNspecified:
// new keys may or may not be iterated — don't rely on this behavior
}
💡 Tips & Tricks
- Performance:
make(map[K]V, hint)pre-allocates buckets when you know the approximate size — avoids the incremental growth/rehashing phase. The hint is not a limit; the map grows as needed. - Safety: never use a plain map across goroutines — concurrent writes cause a fatal (unrecoverable) error. Use
sync.RWMutex+ map for the general case,sync.Mapfor write-rarely/read-many. - Idiom: use struct keys for compound keys —
map[struct{ X, Y int }]Cellis cleaner and faster (one hash lookup) than nested mapsmap[int]map[int]Cell(two lookups). - Idiom: the comma-ok idiom (
v, ok := m[key]) is essential when the zero value is a valid stored value —m["missing"]returns0, indistinguishable from a stored0. Always use comma-ok when absence is meaningful. - Performance: store
*Structin maps when you need to mutate fields — map values aren't addressable, somap[int]Structrequires copy-out/modify/assign-back for each field update.map[int]*Structallows in-place mutation. - Idiom: clear a map with
clear(m)(Go 1.21+) — deletes all entries in one call. Faster than re-allocating withm = make(map[K]V), and keeps the same underlying buckets.
⚠️ Edge Cases & Gotchas
- Writing to nil map panics:
var m map[string]int; m["a"] = 1→panic: assignment to entry in nil map. Alwaysmakeor use a literal. - Reading nil map returns zero value:
var m map[string]int; v := m["a"]→0(no panic). Can mask uninitialized-map bugs. - Concurrent map access is fatal: not a recoverable panic — the program crashes. The runtime detects concurrent writes and aborts immediately.
- Map values are not addressable:
&m["a"]is illegal (entries can move during rehashing). Copy out, modify, assign back — or store*Struct. m[key].field = valueis a compile error: can't take the address of a map value. Use the copy-out pattern or store pointers.- Map iteration order is randomized: never depend on iteration order — it changes between runs. Sort keys for stable output.
- Deleting during iteration is safe:
for k := range m { delete(m, k) }is explicitly allowed. Adding keys during iteration is unspecified. - Slices, maps, functions can't be keys: they're not comparable. Use a string or struct representation as the key.
clear(m)(Go 1.21+): deletes all entries but keeps the map allocated (buckets retained).m = make(map[K]V)creates a new map (old GC'd).clearis faster if you'll reuse the map.make(map, hint)hint is not exact: the actual bucket count is the next power of 2 ≥ hint.make(map[K]V, 100)→ 128 buckets.- Pointer keys compare by address, not value:
map[*int]string— two&xand&ywherex == yare different keys (different addresses). Useintas the key, not*int.
🧠 Quick Quiz
go
m := map[string]*int{}
x := 1
y := 1
m["a"] = &x
m["b"] = &y
fmt.Println(*m["a"] == *m["b"])
fmt.Println(m["a"] == m["b"])
What's printed?
Answer
true
false
*m["a"] == *m["b"]→true— the pointed-to values are both 1.m["a"] == m["b"]→false— the pointers are different (&x≠&y, different addresses).
This is the key distinction for pointer-keyed maps: map[*int]string uses pointer address for equality, not the pointed-to value. Two distinct *int variables pointing to equal values are different keys.
If you want value-based equality, use the value as the key: map[int]string (not map[*int]string).
📚 What's Next
→ 09 — Structs — field layout, embedding vs inheritance, struct tags, and the zero-value-is-useful idiom.