Skip to main content
Go · lesson 10 of 25
Go beginner 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.

Frequently Asked Questions

What happens when you read a key that doesn't exist in a Go map?
You get the zero value of the value type: 0, "", false or nil. To tell a missing key from a stored zero, use the two-value form v, ok := m[key], where ok is false when the key is absent.
Are Go maps safe for concurrent use?
No. Any write concurrent with another read or write is a data race, and the runtime often detects it and crashes the program with 'fatal error: concurrent map writes', which recover cannot catch. Guard the map with a sync.Mutex or RWMutex, or use sync.Map for its specific use cases.
Is map iteration order guaranteed in Go?
No. The language leaves it unspecified and the runtime randomises it, so two loops over the same map can visit keys in different orders. Sort the keys, for example with slices.Sorted(maps.Keys(m)), when you need a stable order.
Why can't I assign to a struct field inside a map?
m[k].Field = v does not compile because a map value is not addressable: the map may move its entries as it grows. Read the struct into a variable, change it, and store it back, or store pointers in the map instead.
What is the difference between a nil map and an empty map?
Both have length 0 and can be read and ranged over safely. Only an empty map, made with make or a literal, can be written to; assigning into a nil map panics with 'assignment to entry in nil map'.