Learn Go Series (#6) - Maps and Sets

Words
2951
Reading
14 min
Listen
Play
7h

Learn Go Series (#6) - Maps and Sets

go-banner.png

What will I learn

  • How to create, read, write and grow a map, Go's built-in hash table;
  • The comma-ok idiom that tells "present but zero" apart from "absent";
  • delete, and why reading a missing key gives you a zero value instead of an error;
  • Why map iteration order is deliberately random, and how to get stable, sorted output;
  • How to build a set from a map with the zero-byte struct{} value;
  • The everyday map patterns: counting, grouping into a map of slices, and nested maps.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-5, especially slices from episode 5;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#6) - Maps and Sets

After the slice, the map is the collection you will reach for most: a hash table that associates keys with values, built right into the language with its own literal syntax. If last episode was about a data structure with famously sharp internals (that aliasing gotcha still haunts people), this one is almost the opposite -- the map is friendly, forgiving, and has very few sharp edges. But the edges it does have are all deliberate design choices, and each one teaches you something about how Go thinks. A missing key returns a zero value rather than throwing. Iteration order is randomised on purpose. And there is no built-in set type at all -- you build one from a map, in one of the more elegant little idioms in the language.

I want to flag something up front, because it is the thread that ties this whole episode together: Go gives you one associative primitive, the map, and then expects you to compose everything else out of it. No set, no Counter, no defaultdict, no OrderedDict -- just map[K]V and a handful of patterns you learn once and reuse forever. Coming from Python (which my audience mostly does), that can feel spartan at first. By the end of this episode I hope it feels like the opposite: a small, sharp tool that does exactly what you tell it, with no hidden behaviour to trip over. Having said that, let us clear last episode's exercises first, then get into it.

Solutions to Episode 5 Exercises

Exercise 1 -- prove the reallocation. Watch the capacity climb in jumps as the slice fills past its capacity:

package main

import "fmt"

func main() {
    s := make([]int, 0, 1)
    for i := 1; i <= 8; i++ {
        s = append(s, i)
        fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
    }
    // for small slices the capacity roughly doubles: 1, 2, 4, 8
}

The key observation, the one to note in a comment: capacity does not creep up one at a time, it leaps -- 1, then 2, then 4, then 8. Each leap is a fresh backing array and a copy of everything so far, which is exactly the amortised-doubling growth we talked through last time.

Exercise 2 -- a safe sub-slice. The full-slice expression caps capacity so the next append is forced to reallocate instead of clobbering the shared array:

package main

import "fmt"

func main() {
    base := []int{1, 2, 3, 4, 5}
    shared := base[:3] // cap 5
    shared = append(shared, 100)
    fmt.Println("shared append clobbers base:", base) // [1 2 3 100 5]

    base = []int{1, 2, 3, 4, 5} // reset
    safe := base[:3:3]          // cap 3 -> append reallocates
    safe = append(safe, 200)
    fmt.Println("safe append leaves base:    ", base) // [1 2 3 4 5]
    fmt.Println("safe:", safe)
}

The whole trick is that third number in base[:3:3]: it pins the capacity to 3, so safe has no spare room, so append has no choice but to allocate a new array and leave base untouched.

Exercise 3 -- remove an element. append(s[:i], s[i+1:]...) shifts the tail left, in place:

package main

import "fmt"

func removeAt(s []int, i int) []int {
    return append(s[:i], s[i+1:]...)
}

func main() {
    nums := []int{10, 20, 30, 40}
    out := removeAt(nums, 1)
    // this shifts within nums's backing array, so nums is mutated too;
    // to leave the original intact, copy first: append([]int{}, s...) then remove.
    fmt.Println(out) // [10 30 40]
}

That comment is the important half of the answer: because the shift happens inside the original backing array, nums is now mutated as a side effect. If you need the original preserved, copy before you remove. Right, on to maps.

Creating, reading and writing a map

A map's type is written map[K]V -- keys of type K, values of type V. You can write a map literal directly, or make an empty one, and then read and write with square brackets just like an array or slice (except the "index" is now a key of any comparable type). len tells you how many pairs it currently holds:

package main

import "fmt"

func main() {
    ages := map[string]int{
        "alice": 30,
        "bob":   25,
    }
    ages["carol"] = 42 // add a new pair
    ages["bob"] = 26   // overwrite an existing one

    fmt.Println("bob is", ages["bob"])
    fmt.Println("people:", len(ages))
}

Writing to a key that does not exist yet adds it; writing to one that does exist overwrites it. There is no separate "insert" versus "update" -- assignment does both, which is one less thing to think about. The key type can be anything comparable (strings, numbers, booleans, even structs whose fields are all comparable), and the value type can be anything at all, including another map or a slice (we will lean on both of those later in the episode).

Now the single most important rule to internalise early, the one that bites newcomers exactly once: the zero value of a map is nil, and while you can safely read from a nil map (you just get zero values back), writing to a nil map panics. So a plain var m map[string]int is not ready to be written to yet -- it is nil until you either assign it a literal or make it:

package main

import "fmt"

func main() {
    var broken map[string]int    // nil map
    fmt.Println(broken["x"])     // 0 -- reading nil is fine
    // broken["x"] = 1           // this line would PANIC: assignment to entry in nil map

    working := make(map[string]int) // now it is ready
    working["x"] = 1                // fine
    fmt.Println(working["x"])       // 1
}

Uncomment that middle line and the program dies with "assignment to entry in nil map". This is a deliberate contrast with slices, where (as we saw last episode) append to a nil slice is perfectly fine because append allocates for you. A map has no append; a bare write has nowhere to allocate from if the map is nil, so Go refuses rather than silently doing something surprising. The habit to build: whenever you declare a map you intend to write to, make it (or give it a literal) in the same breath.

Reading a missing key, and comma-ok

Here is the design choice that trips up quite some people coming from other languages: reading a key that is not present does not error, and does not panic. It returns the value type's zero value -- 0 for an int, "" for a string, false for a bool, nil for a pointer or slice. That is genuinely convenient (the counting pattern later leans on it hard), but it creates an ambiguity: if stock["bananas"] gives you 0, you cannot tell whether bananas are absent or present with a count of zero. The comma-ok form resolves it cleanly:

package main

import "fmt"

func main() {
    stock := map[string]int{"apples": 3, "pears": 0}

    fmt.Println("bananas:", stock["bananas"]) // 0 -- but is it absent or zero?

    if n, ok := stock["pears"]; ok {
        fmt.Println("pears present, count:", n) // present, count 0
    }
    if _, ok := stock["bananas"]; !ok {
        fmt.Println("bananas: not in the map at all")
    }
}

A map read can hand back two values: the value itself, and a boolean that is true if the key was present and false if it was not. That second boolean is the honest answer to "is this key in the map?", and you will reach for it constantly -- for membership tests, for guarding against absent config, for the set idiom coming up. The if v, ok := m[k]; ok { ... } shape (declaring v and ok right in the if init statement, exactly like we did with err back in episode 4) keeps both variables scoped tightly to the block that needs them. It reads almost like English: "if k is in m, with value v, then...".

Note the underscore in the second if: if _, ok := stock["bananas"]; !ok. When you only care about presence and not the value, blank out the value with _ and just keep the boolean. That is the canonical "does this map contain this key?" test.

delete, and safe absence

delete(m, key) removes a pair from the map. And in keeping with the map's whole no-surprises personality, deleting a key that is not there is a harmless no-op -- no panic, no error, nothing. Which means you almost never have to check-before-delete:

package main

import "fmt"

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3}
    delete(m, "b")
    delete(m, "zzz") // absent key: a safe no-op

    fmt.Println("size:", len(m))         // 2
    fmt.Println("b now reads:", m["b"])  // 0 -- gone, reads as zero again
}

Step back and notice the pattern across everything so far. A map read never fails (missing key -> zero value). A delete never fails (missing key -> no-op). When you genuinely need to know whether a key was present, you opt in to that information with comma-ok. That is a very Go way to design an API: the common path has no error handling to write, and the one case where you do need more information is available on request but never forced on you. Fewer error paths, fewer surprises, less ceremony. delete returns nothing, by the way -- it mutates the map in place, so there is no result to assign back (unlike append, which as we saw last episode you must assign back).

Iteration order is random on purpose

Now for the design choice that surprises absolutely everyone the first time they hit it. If you range over a map, the order of the pairs is randomised -- and I do mean actively randomised, freshly shuffled on each run of the program, not merely "unspecified but stable in practice". Run the same loop twice and you can get two different orders:

package main

import (
    "fmt"
    "sort"
)

func main() {
    m := map[string]int{"one": 1, "two": 2, "three": 3}

    keys := make([]string, 0, len(m))
    for k := range m { // ranging a map yields keys (and optionally values)
        keys = append(keys, k)
    }
    sort.Strings(keys)

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

Why on earth would the language designers deliberately shuffle it? Because a hash map has no meaningful order in the first place -- the order pairs happen to sit in is an accident of the hash function and the insertion history, and it was never something you were promised. In early Go the order was merely unspecified, and what happened in practice is exactly what you would predict: people wrote code (and worse, tests) that accidentally depended on the order they happened to observe, and then that code broke when the internals changed. So the Go team made the order visibly random, precisely so that nobody could ever build a dependency on it by accident. It is a rare case of a language deliberately making something less convenient in the small to save you from a whole class of bugs in the large.

The practical consequence: whenever you need stable, deterministic output from a map -- for a test, for a printed report, for anything a human is going to read -- you use the collect-and-sort pattern shown above. Range the map to pull the keys into a slice, sort that slice (with sort.Strings, sort.Ints, or sort.Slice for anything custom), then range the sorted keys and look up each value. Two forms are worth remembering: for k := range m gives you keys only, and for k, v := range m gives you keys and values. Both are randomised, so the sort is how you claw back determinism.

Building a set from a map

Go has no built-in set type. None. And yet you need sets all the time -- de-duplicating, membership tests, "have I seen this before?". The idiom is to build one from a map, and it is genuinely elegant once it clicks. The naive version is map[T]bool, where a present key mapped to true means "in the set". But the truly idiomatic version uses map[T]struct{} -- the empty struct struct{}{} occupies literally zero bytes of memory, so the map stores only the keys, which is exactly what a set is:

package main

import "fmt"

func main() {
    seen := make(map[string]struct{}) // struct{} value = zero bytes

    for _, w := range []string{"go", "rust", "go", "zig", "go"} {
        seen[w] = struct{}{}
    }

    fmt.Println("unique words:", len(seen)) // 3
    _, hasGo := seen["go"]
    fmt.Println("contains go:", hasGo) // true
}

Read the three operations off that code, because they are the set API: adding an element is seen[x] = struct{}{}, membership is the comma-ok read (_, ok := seen[x]), and the count of distinct elements is just len(seen). The struct{}{} looks noisy the first few times (an empty struct literal, two sets of braces, one for the type and one for the value), but you get used to it fast, and it signals intent clearly: "the value here carries no information, only the key matters". If you find it too ugly, map[T]bool reads a little friendlier and costs you one byte per entry -- a perfectly fine trade unless the set is genuinely huge (millions of entries), in which case the zero-byte version earns its keep. Both are correct; pick by taste and scale.

Counting and grouping: the everyday patterns

Two map patterns show up in almost every non-trivial program, and both fall straight out of the zero-value behaviour we have been building on. The first is counting, which leans entirely on "missing key reads as zero": counts[w]++ works even for a brand-new word, because counts[w] reads as 0 before the ++ increments it to 1. No special-casing "first time I have seen this", no if key in map guard -- the zero value does the work for you. The second is grouping, which builds a map[K][]V (a map whose values are slices) and appends to a possibly-nil slice per key:

package main

import (
    "fmt"
    "sort"
    "strings"
)

func main() {
    text := "the cat sat on the mat the cat"

    counts := make(map[string]int)
    for _, w := range strings.Fields(text) {
        counts[w]++ // new keys start at 0, so this just works
    }

    words := make([]string, 0, len(counts))
    for w := range counts {
        words = append(words, w)
    }
    sort.Strings(words)
    for _, w := range words {
        fmt.Printf("%-4s %d\n", w, counts[w])
    }
}

strings.Fields splits a string on runs of whitespace into a []string (we will get properly acquainted with the strings package soon), and counts[w]++ tallies each word with no first-sight special case -- again, purely because the zero value of an int is 0. And notice the tail end of that program is the collect-and-sort pattern from the previous section, because a word-count report is precisely the kind of human-readable output where you do not want random order.

The grouping cousin uses the exact same zero-value trick, but with a slice value and append. Because appending to a nil slice is safe (episode 5!), m[k] = append(m[k], v) works even the first time you touch a key k: m[k] reads as a nil slice, append allocates, and you assign the result back. That one line groups items into buckets with no setup -- for example, grouping names by their first letter, which happens to be one of today's exercises. ;-)

Nested maps: a map whose values are maps

Because a map's value type can be anything, it can be another map, which gives you a natural way to model two-dimensional lookups -- a grid of counts, a per-user set of permissions, an adjacency table. The one wrinkle is that inner map, remember, starts out nil, and writing to a nil map panics, so you have to make each inner map before you write into it:

package main

import "fmt"

func main() {
    // wins[team][opponent] = number of wins
    wins := make(map[string]map[string]int)

    record := func(team, opp string) {
        if wins[team] == nil {
            wins[team] = make(map[string]int) // create the inner map on first sight
        }
        wins[team][opp]++
    }

    record("red", "blue")
    record("red", "blue")
    record("red", "green")

    fmt.Println("red over blue:", wins["red"]["blue"]) // 2
    fmt.Println("red over pink:", wins["red"]["pink"]) // 0 -- absent, reads as zero
}

The if wins[team] == nil { ... make ... } guard is the whole game with nested maps: you cannot skip it, because wins["red"] is a nil map[string]int until you explicitly create it, and wins["red"]["blue"]++ would panic on that nil inner map otherwise. Once the inner map exists, everything nests naturally -- and note the last line still gives you the friendly zero-value read (wins["red"]["pink"] is 0, not a crash) because by then the outer key "red" exists and its inner map simply has no "pink" key. If the nesting guard feels repetitive, that is a hint you might eventually want a small helper or a struct key like map[[2]string]int (arrays are comparable, so a two-string array makes a fine composite key) -- but the nested form is the one you will read most often in other people's code, so it is worth being comfortable with it.

The same idea in Python

Python's dict is the direct analogue of Go's map, but the two languages made almost opposite choices around some of the exact things we covered. Python's dict has kept insertion order since 3.7 (the deliberate opposite of Go's randomisation), it has a real built-in set type (no struct{}{} gymnastics), and the standard library hands you collections.Counter and collections.defaultdict so you rarely hand-roll the counting and grouping patterns:

from collections import Counter, defaultdict

stock = {"apples": 3}
print(stock.get("bananas", 0))  # 0 -- the comma-ok equivalent
print("bananas" in stock)       # membership, like Go's comma-ok ok

counts = Counter("the cat sat on the mat the cat".split())
print(counts.most_common())     # counting, batteries included

groups = defaultdict(list)
for name in ["Ann", "Bob", "Al"]:
    groups[name[0]].append(name) # grouping, no nil-slice dance

seen = set()                    # a real set type
for w in ["go", "rust", "go"]:
    seen.add(w)

So Python gives you ordered dicts, a first-class set, a Counter, and a defaultdict; Go gives you one map primitive and asks you to build the rest from it. It is a real philosophical split, and I do not think either side is simply "right". Python optimises for "the batteries are included and the obvious thing is one import away", which is wonderful for getting something written fast. Go optimises for "there is exactly one associative type, its behaviour is small enough to hold entirely in your head, and you compose everything else from it" -- which trades a little upfront convenience for a language surface you fully understand. Coming from Python, the adjustment is mostly muscle memory: reach for map[K]struct{} where you would reach for set, write counts[k]++ where you would reach for Counter, and remember that the order is random unless you sort it. Do that a dozen times and it stops feeling spartan and starts feeling clean.

Exercises

  1. Invert a map. Given a map[string]int whose values are all unique, write a function that returns a map[int]string mapping each value back to its original key. Print the result with sorted keys so the output is stable, and add a comment noting what would go wrong if the values were not unique.

  2. A word set intersection. Write a function that takes two []string word lists and returns the words present in both, as a sorted slice. Build a map[string]struct{} set from the first list, then walk the second list and keep the ones that are members. (This is the set idiom plus collect-and-sort, together.)

  3. Group by length. Given a slice of words, build a map[int][]string that groups the words by their length (using len), then print each length in sorted order followed by its words. This is the map-of-slices grouping pattern -- lean on the safe nil-slice append.

What we learned

  • A map (map[K]V) is Go's built-in hash table; write it as a literal or make it, and remember its zero value is nil -- reading a nil map is fine, but writing to one panics, so always make before you write;
  • Reading a missing key returns the value type's zero value rather than erroring, so use the comma-ok form (v, ok := m[k]) when you need to distinguish "absent" from "present but zero";
  • delete(m, k) removes a pair and is a safe no-op on absent keys -- like the read, the map API has almost no error paths, by design;
  • Map iteration order is randomised on purpose, to stop you depending on an order that was never promised; collect the keys into a slice and sort them for stable, deterministic output;
  • Build a set from map[T]struct{} (zero-byte values) or map[T]bool; adding is m[x] = struct{}{}, membership is comma-ok, and size is len;
  • The counting pattern (counts[k]++) and the grouping pattern (m[k] = append(m[k], v)) both fall straight out of the zero value plus the safe nil-slice append;
  • Nested maps work because a value can be another map -- just remember to make each inner map before writing to it, or you will hit the nil-map panic.

Next episode we get precise about text: why a Go string is really a read-only slice of bytes, the difference between a byte and a rune, and how to iterate real Unicode correctly instead of accidentally mangling it. See you there.

Bedankt voor het lezen, en tot de volgende! ;-)

scipio@scipio

Learn Go Series (#6) - Maps and Sets | Ecency