Maps in Go Without Surprises for Everyday Programs
Create, read, delete and iterate Go maps, and meet the traps early: nil map writes, unhashable keys, struct values you cannot edit and unsafe concurrent use.

Maps are the second data structure every Go program reaches for, right behind slices, and they have a reputation for being simple. The syntax is. The behavior has a handful of corners that you meet in your first month: a panic on a map that was declared and never made, a compile error when you try to bump a counter inside a struct value, output that changes order between runs, and a crash that appears only under load. This guide covers the whole life of a map in the order the problems arrive, with each claim run. It sits in the Go basics path next to slices, and several of its corners have their own deeper articles that I link as they come up.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64); the memory experiment was repeated on Go 1.27.3
Creating a map and the everyday operations
A map type is written map[K]V: keys of type K, values of type V. You create one with a literal or with make, assign with m[k] = v, read with m[k], remove with delete(m, k) and count with len(m). This program uses each and prints what happens at the edges:
package main
import "fmt"
func main() {
ages := map[string]int{"ada": 36, "grace": 45}
ages["linus"] = 28
fmt.Println(len(ages), ages["ada"], ages["nobody"])
v, ok := ages["nobody"]
fmt.Println(v, ok)
delete(ages, "grace")
delete(ages, "not here")
fmt.Println(len(ages), ages)
clear(ages)
fmt.Println(len(ages), ages == nil)
var nilMap map[string]int
fmt.Println(nilMap["x"], len(nilMap), nilMap == nil)
delete(nilMap, "x")
}3 36 0
0 false
2 map[ada:36 linus:28]
0 false
0 0 trueSeveral things happen in that listing that matter later. Reading a key that is not there returns 0, the zero value of int, with no error. That is convenient for counters and dangerous for everything else, which is why the two-value form v, ok := ages["nobody"] exists and why checking whether a key exists gets its own short article. delete on a missing key is a silent no-op, and clear (added in Go 1.21) empties a map but leaves it usable. The last four lines show a nil map: reading from it works, len is zero, and even delete is allowed. The only thing a nil map refuses is a write, and the panic for that has its own article. Map literals can be nested and can leave out the inner type, as in map[string]Point{"a": {1, 2}}.
What a nil map allows
Since the nil map is where most first panics come from, this table is worth keeping nearby. Every operation here was run in the listing above or in the nil map article.
| Operation on a nil map | Result |
|---|---|
m[k] read | The zero value, no panic |
v, ok := m[k] | The zero value and false |
len(m) | 0 |
for range m | Zero iterations |
delete(m, k) | No effect, no panic |
clear(m) | No effect, no panic |
m[k] = v write | Panics: assignment to entry in nil map |
The asymmetry is deliberate. A nil map behaves like an empty read-only map, so a function can return one for "no data" and its callers can read it without checks. Only code that adds entries has to make sure a real table exists.
Pick a size hint when you know it
make(map[K]V, n) takes a hint for how many entries are coming. It is not a limit, the map still grows past it, but it lets the runtime allocate once instead of growing and rehashing along the way. The difference is measurable with testing.AllocsPerRun, and the last program in this guide also checks what happens to memory when entries are removed. Here is the hint:
package main
import (
"fmt"
"testing"
)
var sink map[int]int
func build(hint int) func() {
return func() {
m := make(map[int]int, hint)
for i := range 10000 {
m[i] = i
}
sink = m
}
}
func main() {
fmt.Println("no hint:", testing.AllocsPerRun(10, build(0)))
fmt.Println("hint of 10000:", testing.AllocsPerRun(10, build(10000)))
}no hint: 81
hint of 10000: 34Without a hint the loop made 81 allocations, and with the hint it made 34. Do not over-read the exact figures: the allocation counts depend on the map implementation, which Go replaced with a new one (a Swiss table design) in Go 1.24, and the exact figures differ between releases. What stays true across them is the direction. If you load a known number of entries, say the rows of a query result or the lines of a file, passing len(rows) to make is free and saves rehashing. If you do not know the size, leave the hint out.
What can be a key
A key type must be comparable, meaning == is defined on it. Booleans, numbers, strings, pointers, channels, arrays of comparable types and structs whose fields are all comparable qualify. Slices, maps and functions do not, and the compiler says so:
package main
import "fmt"
func main() {
byTags := map[[]string]int{}
fmt.Println(byTags)
}# example.test/lab
./main.go:6:16: invalid map key type []stringThe rule has a runtime sibling that the compiler cannot catch. An interface type is comparable as a type, so map[any]string compiles, but a key whose dynamic value is a slice panics when you insert it:
package main
import "fmt"
func main() {
m := map[any]string{}
m["fine"] = "string key"
m[[]int{1, 2}] = "boom"
fmt.Println(m)
}panic: runtime error: hash of unhashable type []int
goroutine 1 [running]:
main.main()
...The panic is runtime error: hash of unhashable type []int, and it appears only on the code path that stores an unhashable value. If a map is keyed by any because it holds heterogeneous identifiers, validate the key type where keys enter.
Two choices of key type deserve a word. Arrays and structs make good composite keys. A grid cell as [2]int or a struct{ X, Y int } avoids building a string such as "3,4", saves the allocation and cannot be mis-parsed; arrays shows the pattern. Floats are poor keys. NaN never equals itself, so a map can hold any number of NaN entries that you cannot look up again, a trap covered in why 0.1 plus 0.2 is not 0.3. Use integers or strings for identifiers and leave floats to computation.
Map values are not addressable
This is the compile error that confuses people most. You store a struct in a map and then try to change one field in place:
package main
import "fmt"
type Stats struct {
Hits int
Misses int
}
func main() {
m := map[string]Stats{"home": {Hits: 1}}
m["home"].Hits++
fmt.Println(m)
}# example.test/lab
./main.go:12:2: cannot assign to struct field m["home"].Hits in mapThe compiler refuses, because m["home"] is a value in a hash table whose address can change when the table grows. It is not a variable you can assign into. The straightforward fix is to read the struct, change the copy and write it back. The other fix is to store pointers, so the map holds the address and the struct lives elsewhere:
package main
import "fmt"
type Stats struct {
Hits int
Misses int
}
func main() {
byValue := map[string]Stats{"home": {Hits: 1}}
s := byValue["home"]
s.Hits++
byValue["home"] = s
fmt.Println(byValue)
byPointer := map[string]*Stats{"home": {Hits: 1}}
byPointer["home"].Hits++
fmt.Println(*byPointer["home"])
}map[home:{2 0}]
{2 0}Choose by size and sharing. For a small struct, value semantics are simpler and there is nothing for the garbage collector to trace. For a large struct, or a value that something else also holds, store pointers. The cost of pointers is a nil check where a missing key gives you nil, and an easy way to share state you did not mean to share.
Iteration, order and the maps package
for k, v := range m visits every entry once, in an order that is deliberately different from run to run. The language does not promise any order, and the runtime randomizes it so that nobody's code comes to depend on one. The reasoning and the experiment are in why map iteration order is random. When you need a stable order, sort the keys and loop over them. Since Go 1.23 the standard library does that in one expression:
package main
import (
"fmt"
"maps"
"slices"
)
func rename(m map[string]int) {
m["renamed"] = 1
}
func main() {
m := map[string]int{"c": 3, "a": 1, "b": 2}
for _, k := range slices.Sorted(maps.Keys(m)) {
fmt.Println(k, m[k])
}
fmt.Println(m)
rename(m)
snapshot := maps.Clone(m)
snapshot["extra"] = 9
fmt.Println(len(m), len(snapshot), maps.Equal(m, snapshot))
}a 1
b 2
c 3
map[a:1 b:2 c:3]
4 5 falsemaps.Keys returns an iterator and slices.Sorted collects it into a sorted slice. One easily missed fact is that fmt.Println(m) prints a map with its keys sorted, as does encoding/json when it encodes a map, so printed output is stable even though iteration is not. The last three lines show two more consequences. A map is a reference to a table, so passing it to rename changed the caller's map. To get an independent copy, use maps.Clone, which is shallow in the same way that copying a slice is. Maps cannot be compared with == except to nil, so maps.Equal is the tool for comparing contents.
Modifying a map during iteration has defined rules. Deleting an entry you have not reached yet means you will not see it. Adding an entry may or may not be visited, and the spec leaves it open. Neither crashes, which makes this a common source of results that differ between runs.
Common shapes: counting, grouping and sets
Three uses cover most of what maps do in application code, and each leans on the zero value so that no initialization is needed:
package main
import (
"fmt"
"slices"
"strings"
)
func main() {
words := strings.Fields("the quick brown fox jumps over the lazy dog the end")
counts := map[string]int{}
byLength := map[int][]string{}
seen := map[string]struct{}{}
var unique []string
for _, w := range words {
counts[w]++
byLength[len(w)] = append(byLength[len(w)], w)
if _, dup := seen[w]; !dup {
seen[w] = struct{}{}
unique = append(unique, w)
}
}
fmt.Println(counts["the"], counts["cat"])
fmt.Println(byLength[5], len(byLength[9]))
fmt.Println(len(seen), slices.Equal(unique, slices.Compact(slices.Clone(unique))))
}3 0
[quick brown jumps] 0
9 truecounts[w]++ works because a missing key reads as zero and the assignment then creates it. byLength[n] = append(byLength[n], w) works because a missing key reads as a nil slice and append accepts nil. And a set is a map to the empty struct, map[T]struct{}, because struct{} takes no memory and the zero value makes membership a simple _, ok := set[x] check. Newer code sometimes spells a set as map[T]bool for readability, which is fine and costs a byte per entry.
A worked example: the most common words
Here are the pieces together, as a small program that counts words in a text and prints the top three with a tie-break so the output is the same on every run:
package main
import (
"cmp"
"fmt"
"slices"
"strings"
"unicode"
)
type entry struct {
word string
count int
}
func main() {
text := `The map is not the territory, but the map is what the program has.
A program that trusts the map without checking the territory is a program with a bug.`
words := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r)
})
counts := make(map[string]int, len(words))
for _, w := range words {
counts[w]++
}
list := make([]entry, 0, len(counts))
for w, n := range counts {
list = append(list, entry{w, n})
}
slices.SortFunc(list, func(a, b entry) int {
if c := cmp.Compare(b.count, a.count); c != 0 {
return c
}
return strings.Compare(a.word, b.word)
})
for _, e := range list[:3] {
fmt.Printf("%-8s %d\n", e.word, e.count)
}
fmt.Println(len(words), len(counts))
}the 6
a 3
is 3
30 16Three habits are on show. The map is made with a size hint from the slice of words, which is a cheap upper bound. The entries are copied out into a slice of structs, because a map cannot be sorted. And the sort has a second key, so the words that tie on count come out in alphabetical order. Without the tie-break the order of equal counts would be the iteration order of the map, and the output would vary between runs, which is the subject of why map iteration order is random.
A struct as a composite key
Earlier I said arrays and structs make good keys. Here is the case that earns it: a flood fill over a small grid, where the set of visited cells is a map keyed by the cell itself, with no string building and no second map for rows:
package main
import "fmt"
type cell struct{ row, col int }
func main() {
grid := []string{
"..#..",
".##..",
"....#",
"#..#.",
}
start := cell{0, 0}
visited := map[cell]struct{}{start: {}}
queue := []cell{start}
for len(queue) > 0 {
c := queue[0]
queue = queue[1:]
for _, d := range []cell{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
n := cell{c.row + d.row, c.col + d.col}
if n.row < 0 || n.row >= len(grid) || n.col < 0 || n.col >= len(grid[0]) {
continue
}
if grid[n.row][n.col] == '#' {
continue
}
if _, seen := visited[n]; seen {
continue
}
visited[n] = struct{}{}
queue = append(queue, n)
}
}
fmt.Println(len(visited), "of 14 open cells reachable")
}13 of 14 open cells reachableThe grid has fourteen open cells and the fill reaches thirteen of them. The fourteenth, at the bottom right, is walled in by two blocks and the edge, and it is the kind of detail that a visited set makes visible. A cell is comparable because both fields are, so visited[n] needs no hashing code from you, and the zero-size struct{} value keeps the set small. If the key had contained a slice, a map or a function, the program would not compile, which is the rule from the section on keys. Keep keys small. A struct with ten fields is hashed in full on every lookup, so an integer ID is better when you have one.
Maps and JSON
Two situations bring maps and JSON together. Decoding into a map you declared works even though the variable starts nil, because json.Unmarshal allocates the map when it is given a pointer. Decoding an object whose shape you do not know goes into map[string]any, and every number arrives as a float64:
package main
import (
"encoding/json"
"fmt"
)
func main() {
var counts map[string]int
err := json.Unmarshal([]byte(`{"a":1,"b":2}`), &counts)
fmt.Println(counts, err)
var doc map[string]any
_ = json.Unmarshal([]byte(`{"name":"ada","age":36,"tags":["x","y"],"nested":{"k":1}}`), &doc)
fmt.Printf("%T %v\n", doc["age"], doc["age"])
name, _ := doc["name"].(string)
tags, _ := doc["tags"].([]any)
nested, _ := doc["nested"].(map[string]any)
fmt.Println(name, len(tags), nested["k"])
}map[a:1 b:2] <nil>
float64 36
ada 2 1The age prints as a float64 with value 36, because JSON has a single number type and Go picks the widest. Each level needs a type assertion, which is tedious and fails quietly when you use the two-value form and ignore ok. For a payload with a known shape, decode into a struct instead, and use a map[string]any only for the parts that really vary. It is the same trade-off as before: the compiler checks a struct and does not check a map. If the JSON comes from a client you do not control, bound the request body with http.MaxBytesReader first, because a map of maps decodes every nested level into memory.
Maps and goroutines
A map is not safe for concurrent use. Two goroutines that write at the same time, or one that writes while another reads, are a data race, and the runtime detects the worst cases and stops the program. This is what it looks like, with the trace cut to the first lines:
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 100000 {
m[i%1000+g] = i
}
}()
}
wg.Wait()
fmt.Println(len(m))
}fatal error: concurrent map writes
goroutine 6 [running]:
internal/runtime/maps.fatal({0x104cc9333?, 0x0?})
runtime/panic.go:1046 +0x20
main.main.func1()
...fatal error: concurrent map writes is not a panic. You cannot recover from it, and it takes the whole process down, which is intentional: a corrupted hash table is worse than a crash. The usual fixes are a sync.Mutex or sync.RWMutex next to the map in a small type that owns both, or sync.Map for the narrow cases it was built for. The trade-offs between those are in sync.Map or a plain map with a mutex and mutexes in Go. Running tests with go test -race finds the unsynchronized access long before production does.
Deleting entries does not give memory back
Because maps grow on demand, it is natural to assume they also shrink. I measured it, since the answer is not in the language specification. The program fills two maps with a million integer pairs each, empties one completely, removes all but a thousand entries from the other, then forces a garbage collection and reports the heap in kilobytes after each step. The final step copies the survivors of the second map into a new map that is sized for them:
package main
import (
"fmt"
"runtime"
)
func heapKB() uint64 {
runtime.GC()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
return ms.HeapAlloc / 1024
}
func main() {
all := make(map[int]int)
most := make(map[int]int)
for i := range 1_000_000 {
all[i] = i
most[i] = i
}
full := heapKB()
for i := range 1_000_000 {
delete(all, i)
}
afterAll := heapKB()
for i := 1000; i < 1_000_000; i++ {
delete(most, i)
}
afterMost := heapKB()
rebuilt := make(map[int]int, len(most))
for k, v := range most {
rebuilt[k] = v
}
most = rebuilt
afterRebuild := heapKB()
fmt.Println("full:", full)
fmt.Println("after deleting every entry:", afterAll)
fmt.Println("after deleting all but 1000:", afterMost)
fmt.Println("after copying into a fresh map:", afterRebuild, len(most), len(all))
}full: 73996
after deleting every entry: 73996
after deleting all but 1000: 74002
after copying into a fresh map: 37092 1000 0Each map held about 36 MB, so the heap sat near 74,000 KB with both full. Deleting every entry of the first map changed nothing: len(all) is zero at the end, and the table behind it is still there. Deleting nearly everything from the second changed nothing either. Only copying the thousand survivors into a freshly sized map released its 36 MB, and the heap fell by about half, leaving the emptied first map still holding its share. I repeated the partial-deletion case on Go 1.27.3 and saw the same figures, so this is not a quirk of one release, although I would still treat it as an implementation detail rather than a promise. A first version of this experiment, which let the emptied map go out of use, appeared to show memory being returned. It was the garbage collector freeing a map nothing referenced, not the map shrinking, which is a good reminder to keep the variable alive when you measure.
The practical rule is for long-lived maps that spike and then settle, such as a cache, a session table or a deduplication set. After a big purge, build a new map from the survivors and drop the old one, instead of trusting delete to return the memory. For short-lived maps it does not matter.
When a map is not the right container
A map is a fine default and a poor fit in a few places. A handful of entries that you search once are faster in a slice with a loop, since hashing has a cost and a slice of eight structs fits in a cache line or two. Anything that needs order, such as a leaderboard or a time series, wants a sorted slice or a tree, because a map gives you neither sorted keys nor ranges. A set that must be shared across goroutines wants a mutex-wrapped type, and a map keyed by a value that changes after insertion (a struct key you later mutate) corrupts lookups, so make keys immutable. If you find yourself writing map[string]any to hold a known set of fields, define a struct; the compiler checks the field names and the map does not.
The definitive references are the specification section on map types, the Go blog's Go maps in action, and the package documentation for maps. Structs, the thing maps most often store, are the subject of the structs guide.


