Skip to content

Maps

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 incrementally
scores := make(map[string]int)
// composite literal — preferred when entries are known upfront
scores := 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.

scores["Charlie"] = 72 // set
fmt.Println(scores["Alice"]) // get → 95
delete(scores, "Bob") // delete (no-op if key absent)

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 panic

This 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.

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")
}

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]int
fmt.Println(m["a"], len(m)) // 0 0 — reading a nil map is completely safe
m["a"] = 1 // panic: assignment to entry in nil map

Verified on 1.26.5, the panic is exactly:

panic: assignment to entry in nil map

This 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.

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 := a
b["x"] = 99
fmt.Println(a["x"]) // 99 — a and b share the same map

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 directly

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.WaitGroup
for 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).

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 error

Verified on 1.26.5 (go build):

./main.go:7:2: cannot assign to struct field m["origin"].X in map

The 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 copy
p.X = 5
m["origin"] = p // store it back
// or: map[string]*Point avoids the copy round-trip entirely
m2 := map[string]*Point{"origin": {0, 0}}
m2["origin"].X = 5 // fine — *Point is addressable through the pointer already stored in the map

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.

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}]) // origin

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.

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 entries

Since 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))
}
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 Go map hashes each key to a bucket; colliding keys probe to another bucket

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.

What you getBenefitCost
built-in hash mapO(1) average lookup, easy to usenot thread-safe — needs a mutex or sync.Map
zero value for missing keyno panic when reading a missing keycan hide a bug if you forget the comma-ok idiom
dynamic sizinggrows automaticallya 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 iterationprevents code from accidentally depending on orderyou must sort keys if you need deterministic output
  • 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.Header is just a map[string][]string — each request gets its own Header, so no locking is needed.

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 (make in a constructor, or a check-and-init on first use)? A zero-value struct field of map type is nil, 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.RWMutex or sync.Map anywhere 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)}.

You read scores["Dave"] but "Dave" was never added. What does Go return?
What does the second return value in v, ok := scores["Alice"] tell you?
What does this print? var m map[string]int fmt.Println(m["a"]) m["a"] = 1
Twenty goroutines all execute m[n] = j on the same map[int]int with no locking. What actually happens when you run it?
Given type Point struct{ X, Y int } and m := map[string]Point{"o": {0, 0}}, why does m["o"].X = 5 fail to compile?