Skip to main content

Go Maps: Key-Value Pairs and Patterns

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 6 min read

Go maps are hash tables: unordered collections of key-value pairs with O(1) average lookup, insert, and delete. The key can be any comparable type (strings, integers, structs without slice/map/function fields). The value can be anything.

Two details that trip up newcomers: maps are reference types (assigning a map copies the reference, not the data), and accessing a nil map to read is safe but writing to one panics. The safe pattern is always make(map[K]V) before writing.

Creating Maps

// Map literal — use when you have initial values
ages := map[string]int{
    "Alice": 30,
    "Bob":   25,
}

// make — use when building incrementally
scores := make(map[string]float64)

// With size hint — avoids rehashing if you know the approximate count
large := make(map[string]int, 1000)  // preallocates for ~1000 entries

// Zero value is nil — reading is safe, writing panics
var bad map[string]int
_ = bad["key"]   // returns 0, no panic
bad["key"] = 1   // panic: assignment to entry in nil map

make(map[K]V, hint) provides a size hint — the map won’t allocate exactly that many slots, but it avoids multiple rehashes during growth. Use it when you’re loading a known number of entries.

Reading: The Comma-Ok Idiom

A map access returns the zero value for missing keys — 0 for int, "" for string, nil for pointers. This makes it impossible to distinguish “key missing” from “key present with zero value” in a single return:

m := map[string]int{"Alice": 0}

n := m["Alice"]  // 0
n  = m["Bob"]    // also 0 — but Bob doesn't exist!

// The comma-ok idiom distinguishes the two cases
if count, ok := m["Alice"]; ok {
    fmt.Println("Alice exists, count:", count)  // count=0
} else {
    fmt.Println("Alice not found")
}

Always use the two-value form when “key exists with zero value” is different from “key missing”. For counters and accumulators where you want to start from zero for new keys, the single-value form is fine — the zero value is the right default.

Modifying Maps

m := map[string]int{"a": 1, "b": 2}

// Update — same syntax as add
m["a"] = 99

// Delete — safe to delete non-existent keys
delete(m, "b")
delete(m, "nonexistent")  // no-op, no panic

// Length
fmt.Println(len(m))  // 1

// Clear all entries (Go 1.21+)
clear(m)
fmt.Println(len(m))  // 0

delete on a non-existent key is explicitly safe — it’s a no-op. There’s no “key not found” error.

Iteration

Map iteration order is randomized by design on every run. Go deliberately randomizes it to prevent code from accidentally depending on insertion order:

m := map[string]int{"a": 1, "b": 2, "c": 3}

for k, v := range m {
    fmt.Printf("%s=%d\n", k, v)  // order varies each run
}

// Keys only
for k := range m { fmt.Println(k) }

// Values only
for _, v := range m { fmt.Println(v) }

When you need deterministic order, collect keys into a slice and sort:

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

for _, k := range keys {
    fmt.Printf("%s=%d\n", k, m[k])
}

Common Patterns

Frequency Counting

The zero-value default makes frequency counting elegant:

func wordFrequency(words []string) map[string]int {
    freq := make(map[string]int)
    for _, w := range words {
        freq[w]++  // starts at 0 for new keys, increments for existing ones
    }
    return freq
}

words := strings.Fields("the quick brown fox the fox")
freq := wordFrequency(words)
// {"the":2, "quick":1, "brown":1, "fox":2}

Grouping

type User struct { Name, City string }

func groupByCity(users []User) map[string][]User {
    groups := make(map[string][]User)
    for _, u := range users {
        groups[u.City] = append(groups[u.City], u)
    }
    return groups
}

append on a nil slice returns a new single-element slice — so the first user in any city starts the group correctly without an explicit nil check.

Set Operations

Go has no built-in set type. Use map[T]struct{} — the empty struct takes zero bytes:

// Build a set
seen := make(map[string]struct{})
for _, s := range items {
    seen[s] = struct{}{}
}

// Membership test
if _, ok := seen["value"]; ok {
    fmt.Println("found")
}

// Set union
union := make(map[string]struct{})
for k := range setA { union[k] = struct{}{} }
for k := range setB { union[k] = struct{}{} }

// Set intersection
inter := make(map[string]struct{})
for k := range setA {
    if _, ok := setB[k]; ok {
        inter[k] = struct{}{}
    }
}

For small sets or where readability matters more than zero-byte values, map[T]bool is also common — use the bool value (true for present) and check with if seen["key"].

Memoization / Caching

var fibCache = map[int]int{}

func fib(n int) int {
    if n <= 1 { return n }
    if v, ok := fibCache[n]; ok { return v }
    result := fib(n-1) + fib(n-2)
    fibCache[n] = result
    return result
}

Counting Unique Values (De-duplication)

func unique(items []string) []string {
    seen := make(map[string]struct{}, len(items))
    result := make([]string, 0, len(items))
    for _, item := range items {
        if _, ok := seen[item]; !ok {
            seen[item] = struct{}{}
            result = append(result, item)
        }
    }
    return result
}

Concurrent Access: sync.Map

Go maps are not safe for concurrent read/write. A goroutine writing while another reads causes a data race, detected by go test -race. Options:

Protect with a mutex:

type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

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

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

Use sync.Map — optimized for write-once, read-many patterns and when keys are stable (not frequently deleted):

var cache sync.Map

// Store
cache.Store("key", "value")

// Load
if v, ok := cache.Load("key"); ok {
    fmt.Println(v.(string))
}

// LoadOrStore — atomic: loads existing or stores if absent
actual, loaded := cache.LoadOrStore("key", "default")
// loaded=false on first call, =true if key already existed

// Delete
cache.Delete("key")

// Iterate
cache.Range(func(k, v any) bool {
    fmt.Println(k, v)
    return true  // return false to stop iteration
})

sync.Map is NOT a drop-in replacement for map + mutex for all cases — it has worse performance for write-heavy workloads with many different keys. Profile before choosing.

Maps Are Reference Types

Assigning a map copies the reference — both variables point to the same underlying data:

a := map[string]int{"x": 1}
b := a          // b and a point to the same map
b["x"] = 99
fmt.Println(a["x"])  // 99 — a was modified through b

To copy a map, iterate and copy explicitly:

func copyMap(m map[string]int) map[string]int {
    out := make(map[string]int, len(m))
    for k, v := range m {
        out[k] = v
    }
    return out
}

Summary

  • Initialize maps with make before writing — reading from nil is safe, writing panics
  • Use the comma-ok idiom (v, ok := m[k]) when “key exists with zero value” is different from “key missing”
  • Map iteration order is random — sort keys when deterministic output is needed
  • map[T]struct{} is the idiomatic set; freq[k]++ relies on zero-value initialization for counters
  • Concurrent map access needs a mutex or sync.Map — the race detector catches violations
  • Assigning a map is a reference copy; deep copy requires explicit iteration

Resources

Comments

👍 Was this article helpful?