Maps
Maps
A map is a hash table: keys to values, no promised order. The boring default is make before the first write, the comma-ok form on every lookup that might miss, and a slice of keys when you need a stable walk.
Mental model
map[K]V needs a comparable key (string, int, a struct of comparable fields — not a slice). The zero map is nil. Read from nil is fine (you get the zero V). Write to nil panics.
Lookup returns the value, or the zero value if the key is missing. That is why prices["ghost"] looks like 0. Comma-ok tells the two apart: cents, ok := prices["tea"].
delete(m, k) is a no-op if the key is absent or m is nil. range visits each key once per walk, in an order you must not depend on.
Worked examples
Case 1: make, write, read
Save as menu_map.go. One map of item to cents.
// menu_map.go
package main
import "fmt"
func main() {
prices := make(map[string]int)
prices["toast"] = 350
prices["tea"] = 250
fmt.Println(prices["toast"])
fmt.Println("len", len(prices))
}Run:
go run menu_map.goOutput:
350
len 2
A composite literal also allocates: prices := map[string]int{"toast": 350, "tea": 250}. Same type, already filled.
Case 2: Comma-ok
Save as comma_ok.go. Missing keys must not look like free tea.
// comma_ok.go
package main
import "fmt"
func main() {
prices := map[string]int{
"toast": 350,
"tea": 0, // comped
}
for _, name := range []string{"toast", "tea", "soup"} {
cents, ok := prices[name]
if !ok {
fmt.Println(name, "not on the menu")
continue
}
fmt.Println(name, cents)
}
}Run:
go run comma_ok.goOutput:
toast 350
tea 0
soup not on the menu
tea is on the menu at 0 cents. soup is not. Only ok distinguishes them.
Case 3: delete and range
Save as delete_item.go. After delete, the key is gone. Range prints the rest — order not guaranteed.
// delete_item.go
package main
import "fmt"
func main() {
prices := map[string]int{
"toast": 350,
"tea": 250,
"soup": 600,
}
delete(prices, "tea")
delete(prices, "ghost")
fmt.Println("len", len(prices))
for item, cents := range prices {
fmt.Println(item, cents)
}
}Run:
go run delete_item.goPossible output:
len 2
soup 600
toast 350
delete of "ghost" did nothing. Do not write tests that assert the order of the two remaining lines.
Case 4: Equality is not deep
Save as map_nil.go. You may compare a map to nil. You may not compare two maps to each other.
// map_nil.go
package main
import "fmt"
func main() {
var unset map[string]int
empty := map[string]int{}
fmt.Println("unset nil", unset == nil)
fmt.Println("empty nil", empty == nil)
fmt.Println("len unset", len(unset), "len empty", len(empty))
}Run:
go run map_nil.goOutput:
unset nil true
empty nil false
len unset 0 len empty 0
empty == unset does not compile (invalid operation: empty == unset (map can only be compared to nil)). Walk both maps if you need deep equality, or use a helper in tests. A struct that contains a map is also not comparable.
Case 5: The tally pattern (zero-value advantage)
Save as tally.go. Because a missing key evaluates to the zero value of its value type (0 for int), you do not need to check if a key exists before incrementing it.
// tally.go
package main
import "fmt"
func main() {
orders := []string{"toast", "tea", "toast", "tea", "toast", "soup"}
counts := make(map[string]int)
for _, item := range orders {
counts[item]++
}
fmt.Println("toast:", counts["toast"])
fmt.Println("tea:", counts["tea"])
fmt.Println("soup:", counts["soup"])
}Run:
go run tally.goOutput:
toast: 3
tea: 2
soup: 1
On the first "toast", counts["toast"] evaluates to 0, gets incremented to 1, and is written back. No if _, ok := counts[item]; !ok boilerplate is needed.
Case 6: Deterministic sorted iteration
Save as sort_keys.go. Map iteration order is deliberately randomized by the Go runtime to prevent programs from relying on hash order. When you print a bill or generate a report, collect keys into a slice and sort them.
// sort_keys.go
package main
import (
"fmt"
"slices"
)
func main() {
prices := map[string]int{
"toast": 350,
"tea": 250,
"soup": 600,
}
keys := make([]string, 0, len(prices))
for k := range prices {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Printf("%-6s %d\n", k, prices[k])
}
}Run:
go run sort_keys.goOutput:
soup 600
tea 250
toast 350
The output order is alphabetical every single run.
The trap
Trap 1: A nil map panics on write
var is not make. Save as nil_map.go:
// nil_map.go
package main
import "fmt"
func main() {
var prices map[string]int
fmt.Println("read missing", prices["tea"])
prices["tea"] = 250
}Run:
go run nil_map.goOutput:
read missing 0
panic: assignment to entry in nil map
goroutine 1 [running]:
main.main()
nil_map.go:9 +0xa5
exit status 2
The read looked polite. The write killed the process. The fix is one line before any assignment: prices = make(map[string]int), or declare with a literal. Returning a nil map from a function is fine if callers only range and look up. Document that, or return an empty map[string]int{} so writes succeed.
Trap 2: Direct mutation of struct fields in a map
If a map stores struct values directly, writing to a field of that struct (m[k].Field = val) does not compile:
cannot assign to struct field m["T1"].Table in map
Map elements are not addressable because map hash tables grow and reallocate buckets dynamically as entries are added. Storing an address to an internal map slot would create dangling pointers when the map resizes.
Save as map_struct_fix.go to see the two correct patterns:
// map_struct_fix.go
package main
import "fmt"
type Ticket struct {
ID int
Table int
}
func main() {
// Pattern A: Reassign the modified value struct
byID := map[string]Ticket{
"T1": {ID: 1, Table: 4},
}
t := byID["T1"]
t.Table = 12
byID["T1"] = t
fmt.Println("Pattern A table:", byID["T1"].Table)
// Pattern B: Use pointers as map values
byPtr := map[string]*Ticket{
"T2": {ID: 2, Table: 8},
}
byPtr["T2"].Table = 15
fmt.Println("Pattern B table:", byPtr["T2"].Table)
}Run:
go run map_struct_fix.goOutput:
Pattern A table: 12
Pattern B table: 15
If the struct is small and values are rarely updated in place, reassign the struct (Pattern A). If the struct is frequently updated or passed to methods, use pointers as values (Pattern B).
The boring rule
make(or a literal) before write.- Comma-ok on lookups that can miss. Do not treat
0or""as “absent.” - Rely on zero values for counters (
counts[k]++) and append accumulators (groups[k] = append(groups[k], item)). deleteis safe on missing keys and on nil maps.- Do not range a map for order. Extract keys and sort them with
slices.Sort. - Do not compare maps except to
nil. - When mutating structs stored in a map, store pointers (
map[K]*V) or copy, mutate, and reassign the value. - Keys must be comparable. If you want a slice as a key, you wanted a
string(join it) or a different structure.
Try this
- In
comma_ok.go, add"soup": 600and confirm the miss path no longer runs. - In
tally.go, add a group accumulatorgrouped := make(map[string][]int)and group ticket IDs under item names usingappend. - In
sort_keys.go, sort keys in reverse order usingslices.Reverse(keys). - In
map_struct_fix.go, writebyID["T1"].Table = 99without copying first. Observe the compiler rejection and explain why map elements are not addressable.