21. Maps: key/value lookups

📖 Reading · 11 min
💡 Every code box below is live — edit it and hit Run.

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.