Maps
Creating a map
Section titled “Creating a map”A map is a hash table that maps keys of type K to values of type V. Create one with make or a composite literal:
// make — preferred when you add entries incrementallyscores := make(map[string]int)
// composite literal — preferred when entries are known upfrontscores := map[string]int{ "Alice": 95, "Bob": 87,}The key type must be comparable (supports ==). Valid key types include string, int, all numeric types, booleans, pointers, arrays, channels, interfaces, and structs whose fields are all comparable. An interface key compiles fine but panics at runtime if the dynamic type it holds turns out to be incomparable (for example a slice); a pointer key compares by address, not by what it points to. Slices, maps, and functions cannot be used as keys.
Set, get, delete
Section titled “Set, get, delete”scores["Charlie"] = 72 // setfmt.Println(scores["Alice"]) // get → 95delete(scores, "Bob") // delete (no-op if key absent)Missing-key zero value
Section titled “Missing-key zero value”Reading a key that does not exist never panics — it returns the zero value for the value type:
fmt.Println(scores["Dave"]) // 0 — not a panicThis is convenient but can hide bugs: you cannot tell whether a key maps to 0 or was never set. Use the comma-ok idiom to distinguish.
The comma-ok idiom
Section titled “The comma-ok idiom”A two-result map lookup returns the value and a boolean indicating whether the key was present:
v, ok := scores["Dave"]if !ok { fmt.Println("Dave not found")}Nil maps: readable, not writable
Section titled “Nil maps: readable, not writable”The zero value of a map type is nil — not an empty, ready-to-use map. A nil map behaves like an empty map for every read operation, but panics on write:
var m map[string]intfmt.Println(m["a"], len(m)) // 0 0 — reading a nil map is completely safem["a"] = 1 // panic: assignment to entry in nil mapVerified on 1.26.5, the panic is exactly:
panic: assignment to entry in nil mapThis is one of the most common AI-generated-Go bugs: a struct field declared as map[string]int with no explicit initialization looks like a normal zero value, and it compiles fine right up until the first write. Always initialize a map you intend to write to — make(map[string]int) or a composite literal — either at declaration or in a constructor.
Maps are reference types
Section titled “Maps are reference types”Assigning a map copies the header, not the data. Both variables point to the same underlying hash table:
a := map[string]int{"x": 1}b := ab["x"] = 99fmt.Println(a["x"]) // 99 — a and b share the same mapMaps as function arguments
Section titled “Maps as function arguments”Because a map value is really just a pointer to the runtime’s hash table, passing a map to a function passes that pointer by value — the function gets its own copy of the header, but both headers point at the same table. There is no way to prevent a function from mutating a map you hand it, and no need to return it — the caller already sees every change:
func setAll(m map[string]int, v int) { for k := range m { m[k] = v }}
scores := map[string]int{"a": 1, "b": 2}setAll(scores, 9)fmt.Println(scores["a"], scores["b"]) // 9 9 — the caller's map was mutated directlyConcurrent map writes are unrecoverable
Section titled “Concurrent map writes are unrecoverable”A Go map is not safe for concurrent access. Two goroutines writing to the same map at the same time do not race quietly — the runtime detects it and crashes the whole process immediately, and this crash cannot be caught with recover():
m := make(map[int]int)var wg sync.WaitGroupfor i := 0; i < 20; i++ { wg.Add(1) go func(n int) { defer wg.Done() for j := 0; j < 10000; j++ { m[n] = j } }(i)}wg.Wait()Verified on 1.26.5 (go run), this reliably terminates with:
fatal error: concurrent map writes
goroutine 27 [running]:internal/runtime/maps.fatal(...)...fatal error (not panic:) is Go’s signal for “this is not a recoverable application error, this is a runtime-integrity violation” — the same category as an out-of-memory kill or a stack overflow. If multiple goroutines need to read and write the same map, protect every access with a sync.RWMutex (RLock/RUnlock for reads, Lock/Unlock for writes), or use sync.Map when the access pattern is dominated by disjoint keys per goroutine or a small number of keys under heavy contention. sync.RWMutex around a plain map is usually the better default — sync.Map optimizes for a narrower workload and loses type safety (its API takes and returns any).
Map values are not addressable
Section titled “Map values are not addressable”You cannot take the address of a map element, and you cannot call a pointer-receiver method or assign to a field of a struct stored directly in a map:
type Point struct{ X, Y int }m := map[string]Point{"origin": {0, 0}}m["origin"].X = 5 // compile errorVerified on 1.26.5 (go build):
./main.go:7:2: cannot assign to struct field m["origin"].X in mapThe same applies to &:
mm := map[string]int{"a": 1}p := &mm["a"] // compile error./main.go:5:8: invalid operation: cannot take address of m["a"] (map index expression of type int)The reason is structural, not arbitrary: a map can rehash and move every entry to a new bucket layout at any time (on grow, or with the Swiss-table implementation used since Go 1.24). A pointer into the old bucket would silently point at stale or reused memory after a rehash, so the compiler refuses to create one in the first place. Fix it with the copy-modify-store pattern, or switch to storing pointers:
p := m["origin"] // load a copyp.X = 5m["origin"] = p // store it back
// or: map[string]*Point avoids the copy round-trip entirelym2 := map[string]*Point{"origin": {0, 0}}m2["origin"].X = 5 // fine — *Point is addressable through the pointer already stored in the mapMutating a map during range
Section titled “Mutating a map during range”Deleting the current key during a range is explicitly safe and specified — the deleted key is simply not produced by later iterations. Adding new keys during a range is a different story: the language spec leaves it unspecified whether a newly-added key is visited in that same loop. Never write a loop whose correctness depends on seeing (or not seeing) a key it just inserted.
Composite keys
Section titled “Composite keys”Any comparable type can be a map key, including a struct. This is a common, idiomatic way to build a multi-part lookup (a 2D coordinate, a (userID, resourceID) pair) without concatenating strings:
type Coord struct{ X, Y int }
grid := map[Coord]string{ {0, 0}: "origin", {1, 1}: "diag",}fmt.Println(grid[Coord{0, 0}]) // originIteration order is randomized
Section titled “Iteration order is randomized”The spec leaves iteration order unspecified, and the runtime deliberately randomizes the starting point of every range loop — two loops in the same run differ. To print map entries deterministically, sort the keys first:
import "sort"
keys := make([]string, 0, len(scores))for k := range scores { keys = append(keys, k)}sort.Strings(keys)for _, k := range keys { fmt.Printf("%s: %d\n", k, scores[k])}Since Go 1.23 the maps and slices packages do this in one line — maps.Keys returns an iterator and slices.Sorted collects and sorts it:
import ("maps"; "slices")
for _, k := range slices.Sorted(maps.Keys(scores)) { fmt.Printf("%s: %d\n", k, scores[k])}Two more modern helpers: clear(m) (Go 1.21) removes every entry, and maps.Clone/maps.Copy replace hand-written copy loops.
Sizing a map up front
Section titled “Sizing a map up front”make(map[K]V, hint) pre-sizes the map’s internal storage for roughly hint entries, avoiding the incremental regrowth (and rehashing) that happens when a map created with make(map[K]V) fills up gradually. hint is only a hint, not a hard limit — the map still grows past it if needed:
big := make(map[int]bool, 100)fmt.Println(len(big)) // 0 — hint pre-sizes storage; it does not create entriesSince Go 1.24, the runtime’s map implementation is a Swiss-table design (open addressing with SIMD-friendly metadata) rather than the earlier bucket-and-overflow-chain layout — it’s faster and uses less memory, but the language-level contract you have already learned in this lesson (no ordering, no addressability, not concurrency-safe) is completely unchanged.
package main
import ( "fmt" "sort")
func main() { // make a map and add entries scores := make(map[string]int) scores["Alice"] = 95 scores["Bob"] = 87 scores["Charlie"] = 72
// Comma-ok idiom v, ok := scores["Bob"] fmt.Println(v, ok) // 87 true
v2, ok2 := scores["Dave"] fmt.Println(v2, ok2) // 0 false
// Delete an entry delete(scores, "Charlie")
// Sort keys for deterministic output keys := make([]string, 0, len(scores)) for k := range scores { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Printf("%s: %d\n", k, scores[k]) }} The second playground focuses on the depth topics above: maps as function arguments, the copy-modify-store pattern for map values, safely deleting during range, and sizing with a hint.
package main
import "fmt"
type Point struct{ X, Y int }
func setAll(m map[string]int, v int) { for k := range m { m[k] = v }}
func main() { // Maps as function arguments: the callee mutates the caller's map directly m := map[string]int{"a": 1, "b": 2} setAll(m, 9) fmt.Println(m["a"], m["b"])
// Map values are not addressable: load, modify, store back points := map[string]Point{"origin": {X: 0, Y: 0}} p := points["origin"] p.X = 5 points["origin"] = p fmt.Println(points["origin"])
// Deleting the CURRENT key during range is safe and specified scores := map[string]int{"a": 1, "b": 2, "c": 3} for k := range scores { if k == "b" { delete(scores, "b") } } fmt.Println(len(scores))
// make with a size hint pre-sizes storage; it does not create entries big := make(map[int]bool, 100) fmt.Println(len(big))} Mental Model
Section titled “Mental Model”flowchart LR H["hash(key)"] --> B0["bucket 0"] H --> B1["bucket 1"] H --> B2["bucket 2 (probed on collision)"] B0 --> E0["key/value slots"] B1 --> E1["key/value slots"] B2 --> E2["key/value slots"]
A map is a hash table: hash(key) picks a bucket, and the entry lives in one of that bucket’s slots (or a neighboring bucket, if the first one is full — “probing”). This single picture explains three rules from this lesson at once: lookup/insert/delete are O(1) on average because hashing jumps straight to a bucket; iteration order is random because there is no reason to visit buckets in any particular order; and a value stored in a bucket slot is not addressable because a grow/rehash can move it to a completely different bucket at any time.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| built-in hash map | O(1) average lookup, easy to use | not thread-safe — needs a mutex or sync.Map |
| zero value for missing key | no panic when reading a missing key | can hide a bug if you forget the comma-ok idiom |
| dynamic sizing | grows automatically | a map never shrinks automatically (the Swiss table since 1.24 returns some memory on grow); build a new map if you need to release memory |
| randomized iteration | prevents code from accidentally depending on order | you must sort keys if you need deterministic output |
Common Misconceptions
Section titled “Common Misconceptions”- A map is safe for concurrent access — it is not — multiple goroutines reading/writing at once causes a race condition and a crash.
- Map iteration order is insertion order — Go deliberately iterates maps in a random order; it can differ on every run.
delete()reduces memory immediately — a Go map does not return the memory used by its buckets after a delete — build a new map if you need to shrink it.len(m)is as slow as iterating —len(map)is O(1); Go tracks the count in the map header.
💡 Real-world example
http.Headeris just amap[string][]string— each request gets its ownHeader, so no locking is needed.
Reading AI-generated code
Section titled “Reading AI-generated code”Maps are one of the easiest places for generated code to compile cleanly and still be wrong. Check for these:
- Is a map field on a struct ever written to without first checking it was initialized (
makein a constructor, or a check-and-init on first use)? A zero-value struct field of map type isnil, and the first write panics. - Does any code try to assign into a field or index of a map value directly (
m[k].Field = x,m[k][i] = x)? Both are compile errors — map values are not addressable. - Does a goroutine write to a shared map without a
sync.RWMutexorsync.Mapanywhere in sight? This compiles and often “works” in testing, then crashes the whole process in production under real concurrency. - Does a loop add new keys to the map it is currently ranging over, expecting a specific, deterministic outcome? The spec leaves this unspecified — the code may work today and break on a different Go version or GOMAXPROCS setting.
- Is a map passed to a function that the author believed would receive “a copy” (some comment like
// safe, m is a local copy)? The copy is only the header — the callee can still mutate every entry.
🐛 Spot the bug
type Config struct { Counts map[string]int}
func NewConfig() *Config { return &Config{}}
func main() { c := NewConfig() c.Counts["hits"]++ fmt.Println(c.Counts)}Show the answer
NewConfig returns a *Config whose Counts field was never initialized — the zero value of map[string]int is nil, not an empty map. c.Counts["hits"]++ is a read-then-write; the read half is fine (returns 0 for a nil map), but the write half panics. Verified on 1.26.5: panic: assignment to entry in nil map. Fix NewConfig to initialize the field: return &Config{Counts: make(map[string]int)}.