Go · lesson 10 of 25
Maps in Go
Go maps run for real: comma-ok lookups, the nil-map panic, random iteration order, struct values you can't assign, sets, and a concurrent-write crash.
By SkillByExample · Updated
A map is Go’s built-in hash table: average constant-time lookup, insert and delete. Maps behave like references — copying a map variable shares the same table — and they come with three rules that the programs below break on purpose.
Literals, make, and the comma-ok lookup
Reading a missing key returns the zero value, so use the two-value form when zero is a legitimate value.
package main
import "fmt"
func main() {
ages := map[string]int{"alice": 30, "bob": 0}
scores := make(map[string]int, 100) // capacity hint, length 0
fmt.Println(ages["alice"], ages["bob"], ages["carol"])
if _, ok := ages["bob"]; ok {
fmt.Println("bob is present")
}
if _, ok := ages["carol"]; !ok {
fmt.Println("carol is missing")
}
ages["carol"] = 41
delete(ages, "alice")
delete(ages, "nobody") // no-op, no error
fmt.Println(ages, len(ages), len(scores))
}
$ go run basics.go
30 0 0
bob is present
carol is missing
map[bob:0 carol:41] 2 0
ages["bob"] and ages["carol"] both returned 0 — one stored, one missing — and only the ok boolean tells them apart. delete on an absent key does nothing. fmt prints maps with their keys sorted, which makes printed maps stable even though iteration order is not.
The nil map panic
A var m map[K]V is nil. Reading it is fine; writing it panics.
package main
import "fmt"
func main() {
var m map[string]int
fmt.Println(m["x"], len(m), m == nil)
for k := range m {
fmt.Println(k) // never runs
}
m["x"] = 1
}
$ go run nilmap.go
0 0 true
panic: assignment to entry in nil map
goroutine 1 [running]:
main.main()
/home/user/gobyex/maps/nilmap/nilmap.go:11 +0x159
exit status 2
Everything up to the assignment worked, then the program crashed and go run reported exit status 2 (the status for an unrecovered panic). Initialise maps with make or a literal before writing — a common bug in struct fields of map type, which start out nil.
Maps are shared, not copied
Assigning a map or passing it to a function shares the table.
package main
import (
"fmt"
"maps"
)
func addDefault(m map[string]string) {
m["lang"] = "go"
}
func main() {
a := map[string]string{"env": "prod"}
b := a
addDefault(b)
fmt.Println(a)
c := maps.Clone(a)
c["env"] = "dev"
fmt.Println(a["env"], c["env"])
}
$ go run shared.go
map[env:prod lang:go]
prod dev
addDefault received b, but a changed too: the map value is a pointer to the table. maps.Clone (Go 1.21+) makes an independent shallow copy.
Iteration order is randomised
range over a map visits every entry once, in an order the runtime varies on purpose.
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
for range 3 {
for k := range m {
fmt.Print(k)
}
fmt.Print(" ")
}
fmt.Println()
fmt.Println(slices.Sorted(maps.Keys(m)))
}
$ go run order.go
abcde deabc abcde
[a b c d e]
Three loops over the same, unmodified map in one run did not all agree. Your strings will differ from these, and from one run to the next; only the sorted line is stable. Code that happens to work with one order (a test comparing joined output, say) will fail intermittently. Sort the keys whenever order matters.
Struct values can’t be modified in place
A map entry isn’t addressable, so you can’t assign to a field of a struct stored by value.
package main
import "fmt"
type Item struct {
Qty int
Price float64
}
func main() {
stock := map[string]Item{"apple": {10, 0.5}}
stock["apple"].Qty = 5
fmt.Println(stock)
}
$ go run structval.go
# command-line-arguments
./structval.go:12:2: cannot assign to struct field stock["apple"].Qty in map
The map may move entries when it grows, so Go won’t hand out a pointer into it. Either copy, modify and store back, or keep pointers in the map:
package main
import "fmt"
type Item struct {
Qty int
Price float64
}
func main() {
stock := map[string]Item{"apple": {10, 0.5}}
it := stock["apple"]
it.Qty = 5
stock["apple"] = it
ptrs := map[string]*Item{"pear": {3, 0.8}}
ptrs["pear"].Qty++ // fine: modifies the Item the pointer points to
fmt.Println(stock["apple"], *ptrs["pear"])
}
$ go run structfix.go
{5 0.5} {4 0.8}
Counting, grouping and sets
Because a missing key reads as zero, m[k]++ and m[k] = append(m[k], v) need no initialisation. A map[T]struct{} is the idiomatic set.
package main
import (
"fmt"
"strings"
)
func main() {
words := strings.Fields("go is fun and go is fast")
counts := map[string]int{}
byLetter := map[byte][]string{}
seen := map[string]struct{}{}
for _, w := range words {
counts[w]++
if _, dup := seen[w]; !dup {
seen[w] = struct{}{}
byLetter[w[0]] = append(byLetter[w[0]], w)
}
}
fmt.Println(counts)
fmt.Println(len(seen), "distinct")
fmt.Println(byLetter['f'], byLetter['i'])
}
$ go run group.go
map[and:1 fast:1 fun:1 go:2 is:2]
5 distinct
[fun fast] [is]
append on a nil slice allocates one, so grouping into map[K][]V works from the first entry. struct{} takes zero bytes, so a set built from it stores only keys.
Concurrent writes crash the program
Maps are not safe for concurrent use. The runtime checks for simultaneous writes and stops the program — a fatal error, not a panic you can recover.
package main
import (
"fmt"
"sync"
)
func main() {
m := map[int]int{}
var wg sync.WaitGroup
for g := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for i := range 100_000 {
m[i] = g
}
}()
}
wg.Wait()
fmt.Println(len(m))
}
$ go run concurrent.go 2>&1 | head -3
fatal error: concurrent map writes
goroutine 6 [running]:
The full output continues with a stack trace for every goroutine; head -3 keeps the first lines, and the goroutine number varies from run to run. On this 4-core machine the crash happened on every one of 30 normal runs, but it is not guaranteed — the runtime’s check is best-effort: run with GOMAXPROCS=1, the same program printed 100000 here without complaint, because the goroutines happened not to overlap. The race is still there. The fix is a mutex around every access:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
m map[int]int
}
func (c *Counter) Set(k, v int) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
func main() {
c := Counter{m: map[int]int{}}
var wg sync.WaitGroup
for g := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for i := range 100_000 {
c.Set(i, g)
}
}()
}
wg.Wait()
fmt.Println(len(c.m))
}
$ go run -race mutex.go
100000
Run under -race, the detector reports nothing and the count is always 100,000 keys. Use sync.RWMutex when reads far outnumber writes, and consider sync.Map only for its documented cases (keys written once and read many times, or goroutines working on disjoint keys).
Practice
1. Write invert(m map[string]int) map[int][]string that groups keys by value, and sort each group so the output is stable.
Solution
package main
import (
"fmt"
"slices"
)
func invert(m map[string]int) map[int][]string {
out := map[int][]string{}
for k, v := range m {
out[v] = append(out[v], k)
}
for _, ks := range out {
slices.Sort(ks)
}
return out
}
func main() {
fmt.Println(invert(map[string]int{"a": 1, "b": 2, "c": 1, "d": 3}))
}
$ go run p1.go
map[1:[a c] 2:[b] 3:[d]]
Without the sort, [a c] might come out as [c a] on some runs, because the range order decides the append order.
2. Given two string slices, print the elements present in both, using a set.
Solution
package main
import "fmt"
func main() {
a := []string{"go", "rust", "zig", "c"}
b := []string{"c", "java", "go"}
set := make(map[string]struct{}, len(a))
for _, s := range a {
set[s] = struct{}{}
}
for _, s := range b {
if _, ok := set[s]; ok {
fmt.Print(s, " ")
}
}
fmt.Println()
}
$ go run p2.go
c go
Iterating over b (a slice) rather than the set keeps the output in b’s order.
3. Fix structval.go by storing *Item in the map, then decrease the apple quantity by 3.
Solution
package main
import "fmt"
type Item struct {
Qty int
Price float64
}
func main() {
stock := map[string]*Item{"apple": {10, 0.5}}
stock["apple"].Qty -= 3
fmt.Printf("%+v\n", *stock["apple"])
}
$ go run p3.go
{Qty:7 Price:0.5}
The map value is now a pointer, and assigning through a pointer is allowed. Watch for missing keys: stock["pear"].Qty would dereference a nil pointer and panic.
4. Write getOrDefault(m map[string]int, k string, def int) int and use it on a nil map.
Solution
package main
import "fmt"
func getOrDefault(m map[string]int, k string, def int) int {
if v, ok := m[k]; ok {
return v
}
return def
}
func main() {
var empty map[string]int
fmt.Println(getOrDefault(empty, "x", 7), getOrDefault(map[string]int{"x": 0}, "x", 7))
}
$ go run p4.go
7 0
Reading a nil map is safe, so no special case is needed. The second call returns the stored 0 rather than the default — exactly the distinction comma-ok exists for.