21. Maps: key/value lookups
A map associates keys with values and finds them fast. Dictionary, hash map, associative array — same idea, and in Go it's built into the language.
Creating and using a map
package main
import "fmt"
func main() {
ages := map[string]int{
"Ada": 36,
"Grace": 45,
}
ages["Katherine"] = 52
ages["Ada"] = 37
fmt.Println(ages["Grace"], len(ages))
fmt.Println(ages)
}
map[string]int reads as "map from string to int". Keys must be
comparable (string, numbers, bool, structs of comparable fields, arrays);
values can be anything at all — including other maps and slices.
The trailing comma after the last entry in the literal isn't optional. Go
requires it whenever the closing brace is on its own line, and gofmt will
add it for you.
make and the nil map trap
package main
import "fmt"
func main() {
counts := make(map[string]int)
counts["go"]++
fmt.Println(counts)
var broken map[string]int
fmt.Println(broken == nil, len(broken), broken["anything"])
// broken["x"] = 1 // PANIC: assignment to entry in nil map
}
A nil map is readable — you get zero values back — but writing to one
panics. That asymmetry catches people out, especially when a map is a struct
field nobody initialised. Rule: create maps with make or a literal before
writing to them.
Missing keys and the comma-ok form
Reading a key that isn't there returns the value type's zero value, not an error:
package main
import "fmt"
func main() {
stock := map[string]int{"apples": 3, "pears": 0}
fmt.Println(stock["bananas"])
v, ok := stock["bananas"]
fmt.Println(v, ok)
v, ok = stock["pears"]
fmt.Println(v, ok)
}
stock["bananas"] and stock["pears"] both give you 0 — one because it's
absent, one because it's genuinely zero. The two-value comma-ok form
tells them apart: ok is true only if the key exists.
Whenever "absent" and "zero" mean different things in your program, use comma-ok. It's the same shape you'll see with type assertions and channel receives later — Go reuses it deliberately.
Deleting
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
delete(m, "b")
delete(m, "zzz")
fmt.Println(len(m))
_, ok := m["b"]
fmt.Println("b present:", ok)
}
delete is a built-in, and deleting a key that isn't there is a no-op, not
an error.
Iteration order is random — on purpose
package main
import (
"fmt"
"sort"
)
func main() {
scores := map[string]int{"carol": 91, "alice": 78, "bob": 85}
keys := make([]string, 0, len(scores))
for k := range scores {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, scores[k])
}
}
Ranging a map directly gives you keys in a randomised order that changes between runs. Go does this so nobody can accidentally depend on an order the implementation never promised.
So: whenever output order matters — printing a report, generating a file, writing a test — collect the keys, sort them, and range the sorted slice. That four-line preamble is one of the most-typed patterns in Go.
for k := range m gives keys only; for k, v := range m gives both.
Counting: the pattern you'll reuse forever
package main
import (
"fmt"
"sort"
"strings"
)
func main() {
text := "the quick brown fox jumps over the lazy dog the end"
counts := make(map[string]int)
for _, word := range strings.Fields(text) {
counts[word]++
}
words := make([]string, 0, len(counts))
for w := range counts {
words = append(words, w)
}
sort.Slice(words, func(i, j int) bool {
if counts[words[i]] != counts[words[j]] {
return counts[words[i]] > counts[words[j]]
}
return words[i] < words[j]
})
for _, w := range words[:3] {
fmt.Printf("%-6s %d\n", w, counts[w])
}
}
counts[word]++ works on a key that doesn't exist yet, because the read
returns 0 and the increment stores 1. No initialisation, no if
key in map check — this is why counting in Go is a one-liner.
Maps as sets
Go has no set type. A map with an empty-struct value is the standard substitute:
package main
import "fmt"
func main() {
seen := map[string]struct{}{}
for _, v := range []string{"a", "b", "a", "c", "b"} {
if _, ok := seen[v]; ok {
fmt.Println("duplicate:", v)
continue
}
seen[v] = struct{}{}
}
fmt.Println("unique count:", len(seen))
}
struct{} is a type with no fields that occupies zero bytes — the value
carries no information, which is exactly right for a set. map[string]bool
works too and reads more simply; use whichever your team prefers.
Maps are references
Unlike arrays, passing a map to a function does not copy it:
package main
import "fmt"
func addOne(m map[string]int) {
m["added"] = 1
}
func main() {
m := map[string]int{}
addOne(m)
fmt.Println(m)
}
The function modified the caller's map. A map value is a small header pointing at shared hash-table data — so mutations inside a function are visible outside it. Handy, and worth being deliberate about.
One more thing maps can't do: ==. Like slices, maps compare only against
nil.
Your turn
Count how many times each letter appears in "banana" and print the counts
for a, b and n in that order:
a=3
b=1
n=2
package main
import "fmt"
func main() {
word := "banana"
// count each letter into a map[string]int, then print a, b, n
}
package main
import "fmt"
func main() {
word := "banana"
counts := make(map[string]int)
for _, ch := range word {
counts[string(ch)]++
}
for _, letter := range []string{"a", "b", "n"} {
fmt.Printf("%s=%d\n", letter, counts[letter])
}
}
That for _, ch := range word deserves an explanation of its own — what
exactly is ch? That's the next lesson: strings, bytes and runes.