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
makebefore 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
Comments