Maps in Go

📚 Comprehensive Guide ⏱️ 25 min read 🎯 Intermediate Level

Understanding Maps in Go

Hash Table Implementation

Maps in Go are hash tables that provide O(1) average-case lookup, insertion, and deletion. They're implemented as a hash table with buckets, where each bucket can hold up to 8 key-value pairs. When buckets overflow, they chain to overflow buckets.

Map Internals

  • Buckets: Each map has 2^B buckets (B is the bucket bits)
  • Load Factor: Maps grow when average items per bucket exceeds 6.5
  • Hash Function: Go uses a fast, non-cryptographic hash
  • Growth: Maps double in size when growing, with gradual evacuation

Map Fundamentals

Declaration and Initialization

Maps must be initialized before use. A nil map panics on write but returns zero value on read. Understanding the difference between nil maps, empty maps, and maps with capacity is crucial.

// Various ways to create maps
var m1 map[string]int           // nil map - read-only!
m2 := make(map[string]int)      // empty map
m3 := make(map[string]int, 100) // with initial space for ~100 items

// Map literal
ages := map[string]int{
    "Alice": 30,
    "Bob":   25,
    "Carol": 35,
}

// Complex key types
type Point struct{ X, Y int }
grid := map[Point]string{
    {0, 0}: "origin",
    {1, 1}: "diagonal",
}

// Map with interface{} values
data := map[string]interface{}{
    "name":  "John",
    "age":   30,
    "active": true,
}

Basic Operations

Map operations in Go are straightforward but have important nuances. The two-value assignment form is idiomatic for checking existence, and the zero value behavior is predictable.

// Setting values
users := make(map[string]User)
users["u123"] = User{Name: "Alice"}

// Getting values - always check existence
user, exists := users["u123"]
if exists {
    fmt.Println("Found:", user.Name)
}

// Delete operation
delete(users, "u123")  // Safe even if key doesn't exist

// Zero value behavior
counts := map[string]int{}
counts["apples"]++  // Works! Zero value of int is 0

// Length
fmt.Println("Map size:", len(counts))

Iteration and Ordering

Range Loops

Map iteration order is deliberately randomized in Go to prevent dependence on unspecified behavior. Each iteration may produce a different order, even for the same map.

⚠️ Iteration Order

Never rely on map iteration order! Go randomizes it intentionally. If you need ordered output, collect keys and sort them first.

// Basic iteration
scores := map[string]int{"Alice": 95, "Bob": 87, "Carol": 92}

for name, score := range scores {
    fmt.Printf("%s: %d\n", name, score)
}

// Keys only
for name := range scores {
    fmt.Println("Student:", name)
}

// Sorted iteration
import "sort"

func sortedMapKeys(m map[string]int) []string {
    keys := make([]string, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    return keys
}

// Use sorted keys
for _, name := range sortedMapKeys(scores) {
    fmt.Printf("%s: %d\n", name, scores[name])
}

Advanced Map Patterns

Map of Slices

Maps combined with slices create powerful data structures for grouping and categorization. This pattern is common for building indexes and grouping related data.

// Grouping pattern
type Student struct {
    Name  string
    Grade string
}

func groupByGrade(students []Student) map[string][]Student {
    groups := make(map[string][]Student)
    for _, s := range students {
        groups[s.Grade] = append(groups[s.Grade], s)
    }
    return groups
}

// Multi-value map pattern
type MultiMap map[string][]string

func (m MultiMap) Add(key, value string) {
    m[key] = append(m[key], value)
}

func (m MultiMap) Get(key string) []string {
    return m[key]  // Returns nil if key doesn't exist
}

Nested Maps

Nested maps require careful initialization. Each level must be created before use, leading to verbose code that can be simplified with helper functions.

// 2D sparse matrix using nested maps
type SparseMatrix map[int]map[int]float64

func NewSparseMatrix() SparseMatrix {
    return make(SparseMatrix)
}

func (m SparseMatrix) Set(row, col int, val float64) {
    if m[row] == nil {
        m[row] = make(map[int]float64)
    }
    m[row][col] = val
}

func (m SparseMatrix) Get(row, col int) float64 {
    if rowMap, ok := m[row]; ok {
        return rowMap[col]
    }
    return 0  // Default value for sparse matrix
}

// Tree structure using maps
type TreeNode map[string]interface{}

func setPath(root TreeNode, path []string, value interface{}) {
    node := root
    for i, key := range path {
        if i == len(path)-1 {
            node[key] = value
        } else {
            if node[key] == nil {
                node[key] = make(TreeNode)
            }
            node = node[key].(TreeNode)
        }
    }
}

Concurrency and Maps

Thread Safety

Maps are not safe for concurrent access. Concurrent reads are safe, but any concurrent write (including delete) requires synchronization. Go provides multiple approaches for thread-safe map access.

⚠️ Race Conditions

  • Concurrent map writes cause runtime panic
  • Use go run -race to detect races
  • Even concurrent read + write is unsafe
  • sync.Map has overhead; use mutexes for better control
// Option 1: Mutex-protected map
type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        m: make(map[string]int),
    }
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.mu.RLock()
    defer sm.mu.RUnlock()
    val, ok := sm.m[key]
    return val, ok
}

func (sm *SafeMap) Set(key string, value int) {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    sm.m[key] = value
}

func (sm *SafeMap) Increment(key string) int {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    sm.m[key]++
    return sm.m[key]
}

// Option 2: sync.Map (for specific use cases)
var cache sync.Map

// Store
cache.Store("key1", "value1")

// Load
if val, ok := cache.Load("key1"); ok {
    fmt.Println("Found:", val)
}

// LoadOrStore
actual, loaded := cache.LoadOrStore("key2", "default")

// LoadAndDelete (Go 1.15+)
val, loaded := cache.LoadAndDelete("key1")

// Range
cache.Range(func(key, value interface{}) bool {
    fmt.Printf("%v: %v\n", key, value)
    return true  // Continue iteration
})

Channel-Based Map Access

For complex scenarios, using channels to serialize map access provides a clean, goroutine-safe interface without explicit locking.

// Map server pattern
type MapOp struct {
    action string  // "get", "set", "delete"
    key    string
    value  int
    resp   chan<int>
}

func mapServer(ops chan MapOp) {
    m := make(map[string]int)
    for op := range ops {
        switch op.action {
        case "get":
            op.resp <- m[op.key]
        case "set":
            m[op.key] = op.value
        case "delete":
            delete(m, op.key)
        }
    }
}

Common Patterns and Use Cases

Set Implementation

Go doesn't have a built-in set type, but maps with empty struct values provide an efficient implementation with O(1) membership testing.

// Set using map[T]struct{}
type StringSet map[string]struct{}

func NewStringSet(items ...string) StringSet {
    s := make(StringSet)
    for _, item := range items {
        s[item] = struct{}{}
    }
    return s
}

func (s StringSet) Add(item string) {
    s[item] = struct{}{}
}

func (s StringSet) Contains(item string) bool {
    _, ok := s[item]
    return ok
}

func (s StringSet) Remove(item string) {
    delete(s, item)
}

func (s StringSet) Union(other StringSet) StringSet {
    result := NewStringSet()
    for k := range s {
        result.Add(k)
    }
    for k := range other {
        result.Add(k)
    }
    return result
}

func (s StringSet) Intersection(other StringSet) StringSet {
    result := NewStringSet()
    for k := range s {
        if other.Contains(k) {
            result.Add(k)
        }
    }
    return result
}

Caching and Memoization

Maps are perfect for implementing caches and memoization, storing computed results to avoid redundant calculations.

// Simple cache with expiration
type CacheItem struct {
    value      interface{}
    expiration time.Time
}

type Cache struct {
    mu    sync.RWMutex
    items map[string]CacheItem
}

func NewCache() *Cache {
    return &Cache{
        items: make(map[string]CacheItem),
    }
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    c.items[key] = CacheItem{
        value:      value,
        expiration: time.Now().Add(ttl),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    item, found := c.items[key]
    if !found {
        return nil, false
    }
    
    if time.Now().After(item.expiration) {
        go c.delete(key)  // Lazy deletion
        return nil, false
    }
    
    return item.value, true
}

// Memoization decorator
func memoize(fn func(int) int) func(int) int {
    cache := make(map[int]int)
    var mu sync.RWMutex
    
    return func(n int) int {
        mu.RLock()
        if val, ok := cache[n]; ok {
            mu.RUnlock()
            return val
        }
        mu.RUnlock()
        
        result := fn(n)
        
        mu.Lock()
        cache[n] = result
        mu.Unlock()
        
        return result
    }
}

Performance and Optimization

Performance Characteristics

Operation Average Case Worst Case Notes
Get O(1) O(n) Worst case with all hash collisions
Set O(1) O(n) May trigger growth and rehashing
Delete O(1) O(n) Doesn't shrink map
Iteration O(n) O(n) Random order each time
len() O(1) O(1) Maintained counter
Growth O(n) O(n) Gradual evacuation

Memory Optimization

Memory Best Practices

  • Preallocate: Use make(map[K]V, hint) when size is known
  • Clear maps: Set to nil or make new to release memory
  • String keys: Consider interning for repeated strings
  • Pointer values: Store pointers for large structs
  • Delete unused: Remove entries to prevent unbounded growth
// String interning for map keys
type StringInterner struct {
    mu    sync.RWMutex
    table map[string]string
}

func (si *StringInterner) Intern(s string) string {
    si.mu.RLock()
    if interned, ok := si.table[s]; ok {
        si.mu.RUnlock()
        return interned
    }
    si.mu.RUnlock()
    
    si.mu.Lock()
    defer si.mu.Unlock()
    si.table[s] = s
    return s
}

// Clearing maps efficiently
func clearMap(m map[string]int) {
    // Option 1: Delete all keys (keeps allocated memory)
    for k := range m {
        delete(m, k)
    }
    
    // Option 2: Replace with new map (releases memory)
    // m = make(map[string]int)
}

Common Pitfalls and Solutions

⚠️ Map Gotchas

  • Nil maps: Can read but not write
  • Non-addressable values: Can't take address of map values
  • Iteration mutations: Safe to delete during iteration
  • Key types: Must be comparable (no slices, maps, functions)
  • Concurrent access: Will panic without synchronization
// Pitfall: Modifying map values
type User struct { Name string; Age int }
users := map[string]User{"alice": {"Alice", 30}}

// This won't compile!
// users["alice"].Age = 31  // Can't modify field of map value

// Solution 1: Copy, modify, write back
u := users["alice"]
u.Age = 31
users["alice"] = u

// Solution 2: Use pointer values
userPtrs := map[string]*User{"alice": {"Alice", 30}}
userPtrs["alice"].Age = 31  // This works!

// Safe deletion during iteration
for k, v := range m {
    if shouldDelete(v) {
        delete(m, k)  // Safe!
    }
}

// Check before type assertion
data := map[string]interface{}{}
if val, ok := data["key"]; ok {
    if str, ok := val.(string); ok {
        fmt.Println("String value:", str)
    }
}

🎯 Practice Exercises

Exercise 1: LRU Cache

Implement a Least Recently Used (LRU) cache using a map and a doubly-linked list.

Exercise 2: Graph Representation

Create an adjacency list representation of a graph using maps and implement DFS/BFS.

Exercise 3: Word Frequency Counter

Build a concurrent word frequency counter that processes multiple files in parallel.

Exercise 4: JSON to Map

Write a function that converts arbitrary JSON to nested maps and handles all types.