A Go map stores values under keys and finds them by hashing. Learn why a missing key gives the zero value, why writing to a nil map panics, and why the iteration order changes from run to run.
A map is how Go looks something up by name. You give it a key, it gives you the value stored under that key, and it does that quickly however many entries there are.
Maps are simple to use and have a handful of rules that catch people out: a missing key isn’t an error, a nil map can be read but not written, and the order you get back is never the order you put things in. This post covers all of them, plus the maps package. Every program below was run on Go 1.26, and its output is pasted from the run.
Creating, reading, writing and deleting
A map type is written map[K]V, where K is the key type and V is the value type. The quickest way to make one is a literal:
package main
import "fmt"
func main() {
stock := map[string]int{
"apples": 5,
"bread": 2,
}
stock["cheese"] = 7 // add a key
stock["apples"] = 4 // replace a value
delete(stock, "bread")
delete(stock, "figs") // not there, so nothing happens
fmt.Println(stock["apples"], stock["cheese"], len(stock))
fmt.Println(stock)
}
It prints:
4 7 2
map[apples:4 cheese:7]
m[key] = value adds the key if it’s new and replaces the value if it isn’t. There’s no separate “insert” and “update”. delete removes a key, and deleting a key that isn’t there is fine. len tells you how many keys the map holds.
The last line looks ordered, but don’t read anything into that. fmt sorts a map’s keys before printing it, so the output is stable. The map itself keeps no order, as a later section shows.
You can also make a map with make, and give it a size hint:
package main
import (
"fmt"
"strconv"
)
func main() {
a := map[string]int{}
b := make(map[string]int)
c := make(map[string]int, 1000)
fmt.Println(len(a), len(b), len(c))
for i := range 1000 {
c[strconv.Itoa(i)] = i
}
fmt.Println(len(c), c["999"])
}
It prints:
0 0 0
1000 999
a and b are the same thing: an empty map, ready to use. c is empty too. The 1000 isn’t a length. It’s a hint that about 1000 entries are coming, so the runtime can set aside enough room up front instead of growing the map several times while you fill it.
Unlike a slice, a map has no capacity you can ask about. cap doesn’t accept a map, and a map grows on its own as you add keys. You never write m = add(m, ...) the way you write s = append(s, x).
A missing key gives you the zero value
Reading a key that isn’t in a Go map doesn’t fail. You get the zero value for the value type, and that’s where most map bugs start:
package main
import "fmt"
func main() {
stock := map[string]int{"apples": 5, "bread": 0}
fmt.Println(stock["bread"], stock["figs"])
n, ok := stock["bread"]
fmt.Println(n, ok)
n, ok = stock["figs"]
fmt.Println(n, ok)
fmt.Println(len(stock))
}
It prints:
0 0
0 true
0 false
2
stock["bread"] and stock["figs"] both print 0. One means “we’re out of bread”. The other means “we’ve never sold figs”. A plain read can’t tell them apart.
The two-value form can. n, ok := stock["figs"] sets ok to true if the key is in the map and false if it isn’t. That’s the comma-ok idiom, and you use it whenever the zero value could be a real value.
The last line matters too. Reading stock["figs"] didn’t add "figs" to the map. The length is still 2. A read never changes a map.
When the zero value can’t be a real answer, you don’t need ok. A map[string]bool used as a set is the usual case: a missing key reads as false, which is exactly the answer you want.
Explain it like I’m ten
Picture a wall of lockers. Each locker has a label on the door, like “apples”, and something inside, like the number 5. That’s a map. The label is the key, and what’s inside is the value.
When you ask for the “apples” locker, you get what’s inside. When you ask for a locker labelled “figs” and there isn’t one, nobody shouts at you. You’re handed an empty box instead. An empty box for numbers holds 0. An empty box for words holds nothing at all.
If you want to know whether the locker was really there, you ask a second question: “and did that locker exist?” That’s the ok.
The precise version
A map doesn’t search every entry to find a key. It runs the key through a hash function, which turns the key into a number. That number picks a small part of the map to look in, and only the entries in that part get compared with your key.
Here’s the idea, drawn with four buckets. The bucket numbers are illustrative, not real hash values:
A lookup hashes the key, uses the hash to pick one bucket, and compares keys only inside that bucket. A key that matches returns its value and true. A key that isn’t there returns the zero value and false. The bucket numbers are illustrative, and Go’s real map layout is more elaborate than four buckets, but the idea is the same: hash to a small place, then look only there.
Here are those steps in words, in case the animation doesn’t play for you:
- The map holds four pairs. Each pair sits in one bucket, chosen by the hash of its key.
m["cai"]runs"cai"through the hash function. Say the hash lands in bucket 2.- The map looks only in bucket 2. It compares keys, finds
cai, and returns 9 andtrue. m["eve"]hashes"eve". Say that lands in bucket 3.- The map looks only in bucket 3. Neither key there is
eve, so it returns the zero value, 0, andfalse. Nothing is added to the map.
The real runtime is more elaborate than the drawing. Since Go 1.24, maps are Swiss tables. Entries live in groups of eight slots, and each group has a control word with one byte per slot. That byte holds a few bits of the key’s hash, so the runtime can rule out most slots without comparing keys at all. Big maps are split into several tables that grow independently. None of that changes what you see from your code: the hash decides where to look, and a miss costs about as little as a hit.
Each map also gets its own random hash seed. The same key lands in different places in two different maps, and in two different runs of your program. That’s why the numbers above can only ever be illustrative, and it’s part of why iteration order isn’t fixed.
Where the analogy breaks: a real empty box would be a new locker you could put things in. Go doesn’t create anything when you read a missing key. It just hands back a zero value, and the map stays exactly as it was.
nil maps: reading works, writing panics
The zero value of a map type is nil, and a nil map behaves like an empty map for everything except storing a key:
package main
import "fmt"
func main() {
var prices map[string]float64
fmt.Println(prices == nil, len(prices), prices["tea"])
for k := range prices {
fmt.Println("never runs", k)
}
delete(prices, "tea")
prices["tea"] = 2.5
fmt.Println("never reached")
}
It prints the first line, then stops:
true 0 0
panic: assignment to entry in nil map
Reading from a nil map, ranging over it, taking its length and deleting from it all work. They’re all questions with an obvious answer when there’s nothing there. Writing is different. A write needs somewhere to put the entry, and a nil map has no table at all.
Why doesn’t Go just allocate one for you? Because a map variable holds a pointer to the map’s table. If the write allocated a new table, only the variable that was written through would point at it. Any copy of that nil map, like the one a caller passed into a function, would still be nil, and the entry would seem to vanish. A panic is louder and more honest.
The fix is to make the map before writing to it, with a literal or make:
package main
import "fmt"
func main() {
var prices map[string]float64
if prices == nil {
prices = make(map[string]float64)
}
prices["tea"] = 2.5
fmt.Println(prices)
}
It prints:
map[tea:2.5]
In real code the nil map usually hides inside something else, such as a struct field nobody initialised. var m map[K]V is fine when you only read. When you’re going to write, start with m := map[K]V{} or make.
Iteration order is not an order
Ranging over a Go map visits every key exactly once, but in no fixed order, and two loops over the same map in the same run can disagree:
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
for k := range m {
fmt.Println("loop 1:", k)
}
for k := range m {
fmt.Println("loop 2:", k)
}
}
One run printed:
loop 1: d
loop 1: e
loop 1: a
loop 1: b
loop 1: c
loop 2: a
loop 2: b
loop 2: c
loop 2: d
loop 2: e
The map didn’t change between the loops. Each range picked its own random starting point.
Running this on Go 1.26 turned up something worth knowing. With a map this small, the orders often look like the insertion order, rotated: a b c d e, then d e a b c, then c d e a b. That’s an accident of how a small Swiss table stores its entries, and it’s exactly the kind of pattern people start relying on. The language promises nothing about it, and the next release is free to change it.
Changing the map while you range over it has rules of its own. Deleting a key you haven’t reached yet means you won’t see it, and deleting the key you’re on is safe. A key added during the loop may or may not be visited, so don’t count on either. Deleting as you go is the common, safe case:
package main
import "fmt"
func main() {
stock := map[string]int{"apples": 5, "bread": 0, "cheese": 7, "dates": 0}
for item, n := range stock {
if n == 0 {
delete(stock, item)
}
}
fmt.Println(stock)
}
It prints:
map[apples:5 cheese:7]
Getting a stable order
When the output needs an order, you choose it yourself, usually by sorting the keys. maps.Keys returns an iterator over the keys, and slices.Sorted collects them into a sorted slice. You can sort by value too:
package main
import (
"cmp"
"fmt"
"maps"
"slices"
"strings"
)
func main() {
votes := map[string]int{"tea": 4, "coffee": 7, "juice": 4, "water": 1}
fmt.Println(slices.Sorted(maps.Keys(votes)))
drinks := slices.Collect(maps.Keys(votes))
slices.SortFunc(drinks, func(a, b string) int {
return cmp.Or(
cmp.Compare(votes[b], votes[a]), // most votes first
strings.Compare(a, b), // then by name
)
})
fmt.Println(drinks)
}
It prints:
[coffee juice tea water]
[coffee juice tea water]
The first line is alphabetical. The second sort puts the most votes first. tea and juice both have 4, so the tie-break on name decides between them. Without a tie-break, two runs could put them in different orders, because the keys arrived in a random order to begin with.
cmp.Or returns the first argument that isn’t zero, which is what makes “sort by this, then by that” a single expression.
Passing a map to a function
A map passed to a Go function isn’t copied. The function gets a copy of the map variable, and that copy points at the same table, so every change shows through:
package main
import "fmt"
func restock(m map[string]int, item string, n int) {
m[item] += n
}
func replace(m map[string]int) {
m = map[string]int{"surprise": 1}
fmt.Println("inside replace:", m)
}
func main() {
stock := map[string]int{"apples": 5}
restock(stock, "apples", 3)
restock(stock, "bread", 2)
replace(stock)
fmt.Println(stock)
}
It prints:
inside replace: map[surprise:1]
map[apples:8 bread:2]
restock changed an existing value and added a brand-new key, and the caller sees both. Compare that with slices. A function that appends to a slice it was given can’t change the caller’s length, because the length lives in the slice header the function copied. A map keeps its size inside the shared table, so adding keys shows through too.
replace is the limit. Assigning a whole new map to m only changes the function’s own variable. The caller still points at the old table. A function that wants to hand back a different map returns it.
m[item] += n also works for a key that isn’t there yet. The read gives 0, and the write stores 0 plus n.
You can’t change a field of a struct in a map
A value stored in a Go map isn’t addressable, so you can’t assign to part of it in place:
package main
import "fmt"
type player struct {
name string
score int
}
func main() {
players := map[string]player{"ana": {name: "Ana"}}
players["ana"].score = 10
fmt.Println(players)
}
The build fails with:
./main.go:12:2: cannot assign to struct field players["ana"].score in map
The reason is the growth you saw in the lookup section. When a map grows, the runtime moves entries to new places. If Go let you hold a pointer to a value inside the map, that pointer could end up pointing at a slot the map had already left. So the language doesn’t let you take the address of a map value, and assigning to one of its fields would need exactly that. &players["ana"] is refused for the same reason.
There are two fixes. Read the value, change the copy and store it back, or store pointers in the map:
package main
import "fmt"
type player struct {
name string
score int
}
func main() {
players := map[string]player{"ana": {name: "Ana"}}
p := players["ana"]
p.score = 10
players["ana"] = p
byPointer := map[string]*player{"ben": {name: "Ben"}}
byPointer["ben"].score = 7
fmt.Println(players["ana"].score, byPointer["ben"].score)
}
It prints:
10 7
The copy-and-store version keeps the map as the only owner of its values. The pointer version is shorter when you update often, but it has a trap: the zero value of a pointer is nil, so byPointer["zed"].score = 1 on a missing key panics. The part on structs, methods and pointers covers when to choose which.
What can be a key
A Go map key must be comparable, meaning == works on it, because the map has to check whether two keys are the same. Slices, maps and functions can’t be compared with ==, so they can’t be keys:
package main
import "fmt"
func main() {
seen := map[[]string]bool{}
fmt.Println(seen)
}
The build fails with:
./main.go:6:14: invalid map key type []string
Strings, numbers, booleans, pointers and channels all work. So do arrays and structs, as long as every element or field is comparable too. A struct key is the clean way to key a map by more than one thing:
package main
import "fmt"
type cell struct {
row, col int
}
func main() {
board := map[cell]string{}
board[cell{0, 0}] = "X"
board[cell{1, 2}] = "O"
fmt.Println(board[cell{1, 2}], len(board))
_, taken := board[cell{2, 2}]
fmt.Println("2,2 taken:", taken)
trips := map[[2]string]int{}
trips[[2]string{"home", "work"}]++
trips[[2]string{"home", "work"}]++
trips[[2]string{"work", "gym"}]++
fmt.Println(trips[[2]string{"home", "work"}], len(trips))
}
It prints:
O 2
2,2 taken: false
2 2
cell{1, 2} built in two different places is the same key, because two structs are equal when all their fields are equal. You don’t need to glue the row and column into a string like "1,2".
One key type gets past the compiler and fails later. With an interface key such as any, the compiler can’t know what you’ll store, so a slice slips through until the program runs:
package main
import "fmt"
func main() {
seen := map[any]bool{}
seen[42] = true
seen["42"] = true
fmt.Println(len(seen))
seen[[]int{4, 2}] = true
}
It prints the first line, then stops:
2
panic: runtime error: hash of unhashable type []int
42 and "42" are different keys, because their types differ. The slice can’t be hashed at all, and the check can only happen at run time.
Worked example: counting words
Counting how often each word appears is the textbook map job, and the zero value makes it a one-liner inside the loop:
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
func main() {
text := "the cat sat on the mat and the cat slept"
counts := map[string]int{}
for _, word := range strings.Fields(text) {
counts[word]++
}
for _, word := range slices.Sorted(maps.Keys(counts)) {
fmt.Println(word, counts[word])
}
}
It prints:
and 1
cat 2
mat 1
on 1
sat 1
slept 1
the 3
counts[word]++ reads the current count, which is 0 the first time a word shows up, adds one and stores it. No “if the key exists” check is needed.
Worked example: grouping into slices
Grouping values under a key is the other everyday job, and a map[string][]string handles it with append:
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
fruit := []string{"apple", "banana", "avocado", "cherry", "blueberry", "apricot"}
byLetter := map[string][]string{}
for _, f := range fruit {
letter := f[:1]
byLetter[letter] = append(byLetter[letter], f)
}
for _, letter := range slices.Sorted(maps.Keys(byLetter)) {
fmt.Println(letter, byLetter[letter])
}
}
It prints:
a [apple avocado apricot]
b [banana blueberry]
c [cherry]
The first time a letter shows up, byLetter[letter] is a nil slice. append on a nil slice works, so the group starts itself. You have to store the result back with byLetter[letter] = ..., for the same reason you write s = append(s, x): append may return a slice over a new array.
Inside each group the fruit stays in input order, because a slice keeps its order. Only the keys needed sorting.
The maps package and clear
The standard library’s maps package, added in Go 1.21, covers the jobs you’d otherwise write loops for:
package main
import (
"fmt"
"maps"
)
func main() {
prices := map[string]int{"tea": 3, "coffee": 4, "cake": 5}
backup := maps.Clone(prices)
prices["tea"] = 99
fmt.Println(backup["tea"], maps.Equal(prices, backup))
maps.DeleteFunc(prices, func(item string, price int) bool {
return price > 4
})
fmt.Println(prices)
clear(prices)
fmt.Println(prices, len(prices), prices == nil)
}
It prints:
3 false
map[coffee:4]
map[] 0 false
maps.Clone makes a new map with the same keys and values, so changing prices left backup alone. The clone is shallow, though. If the values are slices or pointers, both maps share what those point at.
maps.Equal reports whether two maps hold the same keys with the same values. You need it because == doesn’t work between two maps. The only thing a map compares with is nil.
maps.DeleteFunc removes every entry the function returns true for, which is the loop from the iteration section in one call. Cake cost 5, and tea now costs 99, so both went.
clear, a builtin since Go 1.21, removes every entry. The map is empty but still usable, and it isn’t nil.
clear can also do one thing delete can’t. A floating-point NaN is never equal to anything, not even itself, so a NaN key can go in but can never be found again:
package main
import (
"fmt"
"math"
)
func main() {
m := map[float64]string{}
m[math.NaN()] = "first"
m[math.NaN()] = "second"
fmt.Println(len(m))
delete(m, math.NaN())
fmt.Println(len(m))
clear(m)
fmt.Println(len(m))
}
It prints:
2
2
0
Two writes to “the same” NaN key made two entries, and delete couldn’t find either of them. clear doesn’t look keys up, so it removes them anyway. You’ll rarely key a map by floats, but when you do, this is why.
Maps and goroutines
A plain Go map isn’t safe for use from several goroutines at once, and when two goroutines write to it at the same time the runtime can stop the whole program with fatal error: concurrent map writes, which the part on sync and the race detector shows how to prevent.
What to remember
m[k] = vadds or replaces,delete(m, k)removes, andlen(m)counts. Reading a missing key returns the zero value and doesn’t add it.- Use
v, ok := m[k]whenever the zero value could be a real value. - A nil map can be read, ranged over and deleted from, but writing to it panics. Make it with a literal or
makefirst. - Iteration order is never guaranteed, even when a small map seems to keep one. Sort the keys with
slices.Sorted(maps.Keys(m))when order matters. - A map passed to a function shares its table, so new keys and changed values show through.
- Map values aren’t addressable. Copy, change and store back, or store pointers. Keys must be comparable, and a struct makes a good multi-part key.
maps.Clone,maps.Equal,maps.DeleteFuncandclearreplace most hand-written map loops.
A map finds a value by where its key hashes to, so it can’t give you an order, only an answer.