Loading
All articles
Programming 15 min read

Maps in Go: A Visual, Hands-On Guide

Learn Go maps visually: how a hash map finds a value, make and literals, nil maps, missing keys and the comma ok idiom, deleting, why loop order changes, sharing and cloning, valid key types, counting and grouping, a real benchmark against slices, and exercises.

nbkhope
On this page

A teacher has a list of students and the grade each one got on a test. Given a name, you want the grade. You could keep a slice of names and search it from the start every time, but Go has a better tool for looking something up by name.

A map associates keys with values: each student's name (the key) leads to that student's grade (the value). Other languages call the same idea a hash map, a dictionary or an associative array.

In this guide you'll create and use maps, handle keys that don't exist, and see why a loop over a map changes order. You'll also learn how maps are shared between functions, what can be a key, three patterns you'll use constantly, and a benchmark showing when a map really is faster than a slice. Exercises wrap it up.

All outputs below come from real runs with Go 1.22 on Windows. New to Go? Start with Your First Program in Go.

Your first map

1package main
2
3import "fmt"
4
5func main() {
6	grades := make(map[string]int)
7
8	grades["Ana"] = 9
9	grades["Maria"] = 10
10
11	fmt.Println(grades)
12	fmt.Println(grades["Maria"])
13	fmt.Println(len(grades))
14}
map[Ana:9 Maria:10]
10
2

The type map[string]int reads as "a map from string keys to int values", and the general form is map[KeyType]ValueType. make creates the map. grades["Ana"] = 9 adds a key, or updates it if it's already there, and grades["Maria"] reads the value back. len tells you how many keys the map holds.

How does it find "Maria" so quickly? A map is a hash table. A hash function turns the key into a number, and that number points to a small group of entries, called a bucket:

How a map finds a value, for grades["Maria"]. The key "Maria" goes through a hash function, which points directly to bucket 2 out of four buckets, and that bucket holds "Maria": 10. The other buckets hold "Ana": 9 and "Bruno": 7, and one is empty. The hash jumps straight to one bucket, so the map doesn't search every entry. The bucket numbers are made up for this illustration

The map only looks inside that bucket, so the lookup takes about the same time however many entries the map holds.

Creating a map

Besides make, you can write a map literal when you already know the contents:

1package main
2
3import "fmt"
4
5func main() {
6	grades := map[string]int{
7		"Ana":   9,
8		"Maria": 10,
9	}
10	empty := map[string]int{}
11	roomy := make(map[string]int, 1000)
12
13	fmt.Println(grades, empty, roomy)
14}
map[Ana:9 Maria:10] map[] map[]

The second argument to make is a size hint: the map starts with room for about that many entries. It still grows when needed.

Watch out for a map that was declared but never created. Its zero value is nil, and you can read from it but not write to it:

1package main
2
3import "fmt"
4
5func main() {
6	var grades map[string]int
7	fmt.Println(grades == nil, len(grades), grades["Ana"])
8	grades["Ana"] = 9
9}
true 0 0
panic: assignment to entry in nil map

The fix is to create the map with make or a literal before you write to it.

Keys that don't exist

Reading a key that isn't in the map doesn't cause an error. You get the zero value of the value type. That makes this ambiguous:

1package main
2
3import "fmt"
4
5func main() {
6	grades := map[string]int{"Ana": 9, "Bruno": 0}
7
8	fmt.Println(grades["Bruno"])
9	fmt.Println(grades["Carla"])
10
11	grade, found := grades["Bruno"]
12	fmt.Println(grade, found)
13
14	grade, found = grades["Carla"]
15	fmt.Println(grade, found)
16
17	if grade, found := grades["Ana"]; found {
18		fmt.Println("Ana got", grade)
19	}
20}
10
20
30 true
40 false
5Ana got 9

Bruno scored 0, and Carla isn't in the map, yet both reads give 0. The two-value form, known as the comma ok idiom, tells them apart: the second value is true only when the key exists. The last lines show the common style, which declares both variables inside the if.

Updating and deleting

1package main
2
3import "fmt"
4
5func main() {
6	grades := map[string]int{"Ana": 9, "Bruno": 7, "Carla": 8}
7
8	grades["Ana"] = 10
9	grades["Bruno"]++
10	fmt.Println(grades)
11
12	delete(grades, "Carla")
13	delete(grades, "Nobody")
14	fmt.Println(grades)
15
16	clear(grades)
17	fmt.Println(grades, len(grades))
18}
map[Ana:10 Bruno:8 Carla:8]
map[Ana:10 Bruno:8]
map[] 0

delete removes a key, and deleting one that isn't there does nothing. clear, added in Go 1.21, removes every key.

Looping over a map

range gives you each key and value. The order, though, isn't what you might expect:

1package main
2
3import (
4	"fmt"
5	"strings"
6)
7
8func main() {
9	grades := map[string]int{"Ana": 9, "Bruno": 7, "Carla": 8, "Davi": 6, "Elisa": 10}
10
11	orders := map[string]bool{}
12	firsts := map[string]int{}
13	for round := 0; round < 10000; round++ {
14		names := []string{}
15		for name := range grades {
16			names = append(names, name)
17		}
18		orders[strings.Join(names, " ")] = true
19		firsts[names[0]]++
20	}
21
22	fmt.Println(len(orders), "different orders")
23	fmt.Println(firsts)
24}
5 different orders
map[Ana:4927 Bruno:1286 Carla:1279 Davi:1216 Elisa:1292]

(The counts change from run to run.)

Loop order changes, but it isn't evenly random. Which key came first in 10,000 loops over a 5-key map, in one run on Go 1.22.5: Ana 4,927 times, Bruno 1,286, Carla 1,279, Davi 1,216 and Elisa 1,292

The same map, looped over 10,000 times, came out in 5 different orders. Go deliberately starts each loop at a random position, so that nobody can rely on the order. It isn't a fair shuffle, though. On Go 1.22, a small map keeps its entries in one bucket of 8 slots, and the loop starts at a random slot. Three of those slots are empty here and fall through to Ana, so Ana comes first in 4 of every 8 loops. This is an internal detail that later Go versions changed, so don't depend on it either: treat the order as unknown.

When you need a stable order, sort the keys yourself:

1package main
2
3import (
4	"fmt"
5	"sort"
6)
7
8func main() {
9	grades := map[string]int{"Carla": 8, "Ana": 9, "Bruno": 7}
10
11	names := make([]string, 0, len(grades))
12	for name := range grades {
13		names = append(names, name)
14	}
15	sort.Strings(names)
16
17	for _, name := range names {
18		fmt.Println(name, grades[name])
19	}
20}
Ana 9
Bruno 7
Carla 8

fmt.Println already sorts the keys when it prints a map, which is why the outputs in this guide look tidy.

Maps are shared, not copied

A struct is copied when you assign it, but a map isn't. A map variable holds a pointer to the underlying table, so assigning it or passing it to a function shares that table:

1package main
2
3import (
4	"fmt"
5	"maps"
6)
7
8func addBonus(grades map[string]int) {
9	grades["Ana"]++
10}
11
12func main() {
13	first := map[string]int{"Ana": 1}
14
15	addBonus(first)
16	fmt.Println(first)
17
18	second := first
19	second["Ana"] = 2
20
21	third := maps.Clone(first)
22	third["Ana"] = 3
23
24	fmt.Println(first, second, third)
25	fmt.Println(maps.Equal(first, second), maps.Equal(first, third))
26}
map[Ana:2]
map[Ana:2] map[Ana:2] map[Ana:3]
true false
Assigning a map shares it, and maps.Clone copies it. first and second := first are two names with arrows to the same table, which holds "Ana": 2. third := maps.Clone(first) has an arrow to its own table, which holds "Ana": 3

maps.Clone from the standard library makes a separate copy, and maps.Equal compares contents. You need maps.Equal because == doesn't work on maps:

1package main
2
3import "fmt"
4
5func main() {
6	first := map[string]int{"Ana": 9}
7	second := map[string]int{"Ana": 9}
8	fmt.Println(first == second)
9}
./main.go:8:14: invalid operation: first == second (map can only be compared to nil)

What can be a key

A key must be a type that works with ==: strings, numbers, bools, pointers, arrays, and structs made of those. Slices, maps and functions can't be keys:

1package main
2
3import "fmt"
4
5func main() {
6	seats := map[[]string]int{}
7	fmt.Println(seats)
8}
./main.go:6:15: invalid map key type []string

Values have no such limit, and they can be any type at all.

Structs as values

There's one surprise when the values are structs. You can't change a field directly:

1package main
2
3import "fmt"
4
5type Student struct {
6	Grade int
7}
8
9func main() {
10	students := map[string]Student{"Ana": {Grade: 9}}
11	students["Ana"].Grade = 10
12	fmt.Println(students)
13}
./main.go:11:2: cannot assign to struct field students["Ana"].Grade in map

Reading from a map gives you a copy, so changing it would be lost. You can either write the whole value back or store pointers:

1package main
2
3import "fmt"
4
5type Student struct {
6	Grade int
7}
8
9func main() {
10	students := map[string]Student{"Ana": {Grade: 9}}
11	student := students["Ana"]
12	student.Grade = 10
13	students["Ana"] = student
14	fmt.Println(students)
15
16	pointers := map[string]*Student{"Ana": {Grade: 9}}
17	pointers["Ana"].Grade = 10
18	fmt.Println(pointers["Ana"].Grade)
19}
map[Ana:{10}]
10

Three patterns you'll use constantly

Counting. The zero value makes this short, because a new key starts at 0:

1package main
2
3import (
4	"fmt"
5	"strings"
6)
7
8func main() {
9	text := "the cat and the dog and the bird"
10
11	counts := map[string]int{}
12	for _, word := range strings.Fields(text) {
13		counts[word]++
14	}
15
16	fmt.Println(counts)
17}
map[and:2 bird:1 cat:1 dog:1 the:3]

Grouping. The zero value of a slice is nil, and append handles that fine:

1package main
2
3import "fmt"
4
5func main() {
6	names := []string{"Ana", "Bruno", "Alice", "Bia"}
7
8	groups := map[string][]string{}
9	for _, name := range names {
10		letter := name[:1]
11		groups[letter] = append(groups[letter], name)
12	}
13
14	fmt.Println(groups)
15}
map[A:[Ana Alice] B:[Bruno Bia]]

A set. Go has no set type, so a map from the item to bool does the job. A missing key reads as false:

1package main
2
3import "fmt"
4
5func main() {
6	seen := map[string]bool{}
7
8	for _, name := range []string{"Ana", "Bruno", "Ana"} {
9		if seen[name] {
10			fmt.Println("duplicate:", name)
11			continue
12		}
13		seen[name] = true
14	}
15
16	fmt.Println(len(seen))
17}
duplicate: Ana
2

Maps and goroutines

Maps aren't safe for writing from several goroutines at once. When we tried it with eight goroutines, Go stopped the whole program with fatal error: concurrent map writes, and that's an error recover can't catch. Protect the map with a mutex:

1package main
2
3import (
4	"fmt"
5	"sync"
6)
7
8type SafeCounts struct {
9	lock   sync.Mutex
10	counts map[string]int
11}
12
13func (safe *SafeCounts) Add(key string) {
14	safe.lock.Lock()
15	defer safe.lock.Unlock()
16	safe.counts[key]++
17}
18
19func main() {
20	safe := &SafeCounts{counts: map[string]int{}}
21
22	var group sync.WaitGroup
23	for worker := 0; worker < 100; worker++ {
24		group.Add(1)
25		go func() {
26			defer group.Done()
27			safe.Add("visits")
28		}()
29	}
30	group.Wait()
31
32	fmt.Println(safe.counts)
33}
map[visits:100]

(defer makes sure the unlock always happens. See How to Use defer in Go.)

Is a map always faster than a slice?

We measured it with testing.Benchmark on Go 1.22, looking up every string key in turn. One version scans a slice of key-value pairs, and the other uses a map:

A map wins, but not at every size. Nanoseconds per lookup on a logarithmic scale, on Go 1.22.5 on Windows, median of 11 rounds. With 5 elements: slice 10.59, map 12.96. With 10: slice 16.85, map 10.21. With 100: slice 126.38, map 10.40. With 1,000: slice 1,176, map 13.32. With 10,000: slice 11,329, map 27.52
ElementsScanning a sliceMap lookup
510.59 ns12.96 ns
1016.85 ns10.21 ns
100126.38 ns10.40 ns
1,0001,175.53 ns13.32 ns
10,00011,329.19 ns27.52 ns

The slice's time grows with its size, while the map barely moves. With 5 elements, the slice was actually about 20% faster, because hashing a key has a fixed cost. By 10 elements the map was ahead, and at 10,000 it was about 400 times faster. The map's small rise at the end most likely comes from the data no longer fitting as well in the processor's cache.

So don't reach for a map only for speed when you have a handful of items, but do use one as soon as the collection can grow.

The size hint matters as well. Filling a map with 100,000 integers took a median of 5.34 ms starting from an empty make(map[int]int), and 3.20 ms with make(map[int]int, 100000), about 40% less, because the map never had to grow along the way.

Your turn: practice

Try these before peeking at the answers. Run each with go run ..

Exercise 1: Predict the output (warm-up). What does this print?

1package main
2
3import (
4	"fmt"
5	"maps"
6)
7
8func main() {
9	first := map[string]int{"a": 1}
10	second := first
11	third := maps.Clone(first)
12
13	second["a"] = 2
14	third["a"] = 3
15
16	fmt.Println(first["a"], second["a"], third["a"], first["b"])
17}

Exercise 2: Letter count. Count how many times each letter appears in "banana", and print the map. Hint: range over a string gives you runes, and string(letter) turns one back into a string.

Exercise 3: Predict the stock. What does this print, and why?

1package main
2
3import "fmt"
4
5func main() {
6	stock := map[string]int{"apple": 0}
7
8	quantity, found := stock["apple"]
9	fmt.Println(quantity, found)
10
11	quantity, found = stock["pear"]
12	fmt.Println(quantity, found)
13
14	delete(stock, "pear")
15	fmt.Println(len(stock))
16}

Exercise 4: Invert a map. Write invert(numbers map[string]int) map[int]string, which swaps keys and values. Try it with {"one": 1, "two": 2}.

Exercise 5: Group anagrams (challenge). Two words are anagrams when they have the same letters. Group listen, silent, cat, enlist and act into a map[string][]string, using each word's letters in sorted order as the key.

Quick quiz

  1. What happens when you read a key that doesn't exist?
  2. How do you tell a missing key from a value of zero?
  3. What happens when you write to a nil map?
  4. Why shouldn't you rely on the order of a range over a map?
  5. Does assigning a map to another variable copy it?
  6. Can a slice be a map key? Can it be a map value?

Answers

Exercise 1: first and second share one table, while third has its own. The key "b" doesn't exist, so it reads as 0:

2 2 3 0

Exercise 2: The zero value does the work, as in the counting pattern:

1package main
2
3import "fmt"
4
5func main() {
6	counts := map[string]int{}
7	for _, letter := range "banana" {
8		counts[string(letter)]++
9	}
10	fmt.Println(counts)
11}
map[a:3 b:1 n:2]

Exercise 3: "apple" exists with the value 0, "pear" doesn't exist, and deleting a missing key does nothing:

0 true
0 false
1

Exercise 4: Create the new map with the right size, then swap each pair. If two keys shared a value, one of them would be lost:

1package main
2
3import "fmt"
4
5func invert(numbers map[string]int) map[int]string {
6	inverted := make(map[int]string, len(numbers))
7	for name, number := range numbers {
8		inverted[number] = name
9	}
10	return inverted
11}
12
13func main() {
14	fmt.Println(invert(map[string]int{"one": 1, "two": 2}))
15}
map[1:one 2:two]

Exercise 5: Anagrams give the same key once their letters are sorted, so they land in the same group:

1package main
2
3import (
4	"fmt"
5	"sort"
6)
7
8func sortedLetters(word string) string {
9	letters := []byte(word)
10	sort.Slice(letters, func(first, second int) bool {
11		return letters[first] < letters[second]
12	})
13	return string(letters)
14}
15
16func main() {
17	words := []string{"listen", "silent", "cat", "enlist", "act"}
18
19	groups := map[string][]string{}
20	for _, word := range words {
21		key := sortedLetters(word)
22		groups[key] = append(groups[key], word)
23	}
24
25	fmt.Println(groups)
26}
map[act:[cat act] eilnst:[listen silent enlist]]

Quiz: (1) You get the zero value of the value type, with no error. (2) With the comma ok form: value, found := grades[key]. (3) The program panics. Reading from one is fine. (4) Go changes it on purpose, and it can vary from one loop to the next. (5) No. Both variables share the same table, and maps.Clone makes a real copy. (6) A slice can't be a key, because it doesn't work with ==, but it can be a value.

Cheat sheet

I want to…Write
Create an empty mapgrades := make(map[string]int)
Create one with contentsgrades := map[string]int{"Ana": 9}
Reserve roommake(map[string]int, 1000)
Add or updategrades["Ana"] = 9
Readgrades["Ana"]
Check whether a key existsgrade, found := grades["Ana"]
Remove a keydelete(grades, "Ana")
Remove every keyclear(grades)
Count the keyslen(grades)
Loopfor name, grade := range grades { … }
Loop in a stable orderCollect the keys, then sort.Strings(names)
Copymaps.Clone(grades)
Comparemaps.Equal(first, second)
Count thingscounts[word]++
Group thingsgroups[key] = append(groups[key], item)
Make a setseen := map[string]bool{}

Wrapping up

A map answers "what's the value for this key?" quickly:

  • map[KeyType]ValueType links keys to values, using a hash table under the hood.
  • Create it before writing. Writing to a nil map panics, while reading from one is fine.
  • Missing keys give the zero value. Use value, found := … to tell the difference.
  • Loop order isn't reliable. Sort the keys when the order matters.
  • Maps are shared, not copied. Use maps.Clone for a copy and maps.Equal to compare.
  • Keys must work with ==, so slices can't be keys.
  • You can't change a struct field inside a map directly. Write the value back, or store pointers.
  • Counting, grouping and sets are the patterns you'll use most.
  • Concurrent writes need a mutex, or the program crashes.
  • Maps beat slices as collections grow: 27.52 ns vs. 11,329.19 ns at 10,000 elements, though a slice won at 5.

That's the end of the Go series. Go back through it, starting from Your First Program in Go, or revisit Structs in Go and Pointers in Go.

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.