Maps
A map in Go is a built-in data structure that stores key-value pairs, giving you fast lookup, insertion, and deletion by key instead of by numeric index. Maps are the tool of choice whenever you need to associate one piece of data with another — counting word frequencies, indexing records by ID, or building a set of unique values. Under the hood a Go map is a hash table, and understanding how it stores and finds entries will help you avoid the handful of gotchas that trip up almost every Go beginner.
Overview: How Maps Work in Go
A map is Go’s built-in associative container: a collection of key-value pairs where each key appears at most once. You declare a map type as map[KeyType]ValueType, and the runtime implements it as a hash table. Internally, a map variable is not the table itself — it is a small header holding a pointer to a runtime structure (the compiler and runtime call this hmap) that owns an array of buckets. Because a map variable only holds a pointer, maps are a reference type: when you assign a map to another variable or pass it to a function, you copy the pointer, not the underlying data. Both variables end up pointing at the same table, so a write through one is visible through the other. This is different from an array or a plain struct, which are copied fully on assignment.
The zero value of a map is nil. A nil map behaves like an empty map for every read operation: indexing it returns the value type’s zero value, len on it is 0, and ranging over it simply does nothing. But a nil map has no backing table to write into, so writing to a nil map panics at runtime. This is the single most common map bug for newcomers, and it is covered in detail in Common Mistakes below.
Map keys must be a comparable type: booleans, numbers, strings, pointers, channels, interfaces, and structs or arrays built entirely from comparable types. Slices, maps, and functions are not comparable, so they cannot be used as map keys, and for the same reason two maps cannot be compared with == (only compared to nil).
Go also deliberately randomizes map iteration order on every run. This is not an accident of implementation — the runtime actively randomizes the starting bucket and offset for a range loop specifically so that programs cannot come to depend on a particular order. If you need a predictable order, you must sort the keys yourself.
Finally, maps grow as you insert entries. When the average number of entries per bucket (the load factor) crosses a threshold, the runtime allocates a larger bucket array and incrementally migrates old entries into it across subsequent map operations, rather than pausing to rehash everything at once. This keeps individual map operations fast and amortized O(1) even while the map is actively growing.
Syntax
The core forms you will use to declare, create, and operate on a map:
// declare and initialize a map literal
var m map[KeyType]ValueType // zero value: nil map (read-only, cannot write)
m2 := map[string]int{} // empty, non-nil map, ready to write
m3 := make(map[string]int) // also empty, non-nil, ready to write
m4 := make(map[string]int, 100) // non-nil, with a size hint for fewer allocations
m3["key"] = 42 // add or update an entry
value := m3["key"] // read; returns the zero value if key is absent
value, ok := m3["missing"] // "comma ok" idiom: ok reports whether key existed
delete(m3, "key") // remove an entry (no-op if key absent)
n := len(m3) // number of entries
| Form | Meaning |
|---|---|
map[K]V |
the map type: keys of type K, values of type V |
make(map[K]V) |
creates an empty, non-nil map ready for writes |
make(map[K]V, n) |
creates an empty map with a capacity hint of n entries |
m[key] = value |
insert a new entry or overwrite an existing one |
v := m[key] |
read; returns the zero value of V if the key is absent |
v, ok := m[key] |
“comma ok” form; ok is false if the key is absent |
delete(m, key) |
remove key (safe no-op if the key is absent) |
len(m) |
number of entries currently in the map |
for k, v := range m |
iterate all entries, in unspecified (randomized) order |
Examples
Example 1: Creating and Using a Map
The simplest way to create a map is a map literal, and you can add new entries afterward with plain index assignment.
package main
import "fmt"
func main() {
ages := map[string]int{
"Alice": 30,
"Bob": 25,
}
ages["Charlie"] = 35
fmt.Println("Alice's age:", ages["Alice"])
fmt.Println("Number of people:", len(ages))
}
Output:
Alice's age: 30
Number of people: 3
The literal creates a non-nil map with two entries, then ages["Charlie"] = 35 adds a third. Indexing with ages["Alice"] looks the key up in the hash table and returns its value, and len(ages) reports the current entry count.
Example 2: Checking Existence and Deleting
Because indexing a missing key silently returns the zero value, you need the “comma ok” form whenever the zero value could be a legitimate stored value, or whenever you simply need to know if a key is present at all.
package main
import "fmt"
func main() {
stock := map[string]int{
"apples": 10,
"bananas": 5,
}
if qty, ok := stock["apples"]; ok {
fmt.Println("apples in stock:", qty)
}
if _, ok := stock["cherries"]; !ok {
fmt.Println("cherries not tracked")
}
delete(stock, "bananas")
fmt.Println("bananas after delete:", stock["bananas"])
fmt.Println("map size after delete:", len(stock))
}
Output:
apples in stock: 10
cherries not tracked
bananas after delete: 0
map size after delete: 1
stock["apples"] exists, so ok is true and the quantity prints. stock["cherries"] was never inserted, so ok is false. After delete(stock, "bananas") removes that entry, indexing "bananas" again returns the zero value 0 — indistinguishable from a key that legitimately holds zero, which is exactly why the comma-ok form exists.
Example 3: A Realistic Word-Frequency Counter
A very common real-world use of maps is counting occurrences. Because map iteration order is randomized, this example collects the keys into a slice and sorts them before printing, so the output is deterministic.
package main
import (
"fmt"
"sort"
"strings"
)
func main() {
text := "the quick brown fox jumps over the lazy dog the fox runs"
words := strings.Fields(text)
counts := make(map[string]int)
for _, w := range words {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
Output:
brown: 1
dog: 1
fox: 2
jumps: 1
lazy: 1
over: 1
quick: 1
runs: 1
the: 3
strings.Fields splits the sentence into words, and counts[w]++ reads the current count (0 if absent), adds one, and writes it back — a single expression doing a read-modify-write against the map. The keys are then copied into a slice and sorted with sort.Strings purely for predictable output; the map itself never guarantees any order.
How It Works Step by Step
When you execute m[key] = value, the runtime performs roughly these steps:
- It computes a hash of
keyusing a hash function seeded uniquely per map instance (this per-map seed is part of why iteration order is unpredictable across different maps and different runs). - The low bits of the hash select a bucket in the map’s bucket array. Each bucket holds a small fixed number of key-value slots (eight) plus a pointer to an overflow bucket if more than eight keys hash to the same bucket.
- Within the bucket, the runtime first compares cheap one-byte “top hash” values to narrow down candidate slots, then compares full keys with
==to find an exact match or an empty slot. - If a match is found, the value is overwritten in place; if not, the key-value pair is written into an empty slot (allocating an overflow bucket first if the bucket is full).
- If the map’s overall load factor has grown too high, the runtime allocates a larger bucket array and begins incrementally copying old entries into it a little at a time on subsequent map operations, rather than stopping everything to rehash at once.
A read (v := m[key]) walks the same hash-then-bucket path but only looks, never writes; if no match is found it returns the zero value of the value type. A nil map short-circuits this entirely: since there is no allocated hmap at all, a read on a nil map returns the zero value immediately without touching any bucket logic, while a write has nowhere to go and panics. delete(m, key) follows the same lookup and, on a match, clears that slot so the entry no longer counts toward len(m).
Common Mistakes
Mistake 1: Writing to a Nil Map
Declaring a map with var gives you a nil map. Reading from it is fine, but writing to it panics.
package main
import "fmt"
func main() {
var m map[string]int
m["count"] = 1
fmt.Println(m)
}
Output:
panic: assignment to entry in nil map
(the program crashes before the Println line ever runs)
This compiles without complaint because the syntax and types are valid — the mistake only shows up at runtime. Always initialize a map with make or a literal before writing to it.
package main
import "fmt"
func main() {
m := make(map[string]int)
m["count"] = 1
fmt.Println(m)
}
Output:
map[count:1]
Mistake 2: Assuming Map Iteration Order
New Go programmers often expect a range over a map to visit keys in insertion order, or at least in some consistent order. It does not — the order is randomized by the runtime on every execution.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v)
}
// order of a, b, c is not guaranteed and can differ between runs
If you need deterministic output, collect the keys into a slice, sort them, and range over the sorted slice instead — exactly the pattern used in the word-frequency example above.
Mistake 3: Comparing Maps with ==
Maps are not comparable in Go except against nil, so this does not even compile:
m1 := map[string]int{"a": 1}
m2 := map[string]int{"a": 1}
if m1 == m2 {
fmt.Println("equal")
}
// compile error: invalid operation: m1 == m2 (map can only be compared to nil)
To compare the contents of two maps, use reflect.DeepEqual, or write a manual loop for performance-sensitive code.
package main
import (
"fmt"
"reflect"
)
func main() {
m1 := map[string]int{"a": 1, "b": 2}
m2 := map[string]int{"a": 1, "b": 2}
fmt.Println("equal:", reflect.DeepEqual(m1, m2))
}
Output:
equal: true
Best Practices
- Always initialize a map with
makeor a literal before writing to it — never write to a zero-value (nil) map. - Use the comma-ok form (
v, ok := m[key]) whenever the zero value is a legitimate stored value and you need to tell “absent” apart from “present but zero”. - Never rely on map iteration order; sort keys explicitly whenever you need deterministic or user-facing output.
- Pass a capacity hint to
make(map[K]V, n)when you know roughly how many entries you’ll insert, to reduce the number of grow-and-rehash cycles. - Use
map[T]struct{}as the idiomatic zero-memory representation of a set, rather thanmap[T]bool. - Go’s built-in map is not safe for concurrent reads and writes from multiple goroutines; protect shared maps with a
sync.Mutex/sync.RWMutex, or usesync.Mapfor specific concurrent-access patterns. - Reserve
reflect.DeepEqualfor tests or infrequent comparisons; it uses reflection and is slower than a hand-written comparison loop on a hot path. - Remember that a map value holds a reference to shared state — if you don’t want a function to mutate the caller’s map, copy the entries into a new map first.
Practice Exercises
- Write a program that counts votes for candidates from a slice of strings and prints each candidate’s vote count with candidates sorted alphabetically. Hint: reuse the word-frequency counting pattern from Example 3.
- Write a function
intersect(a, b []int) []intthat returns the elements common to both slices, using amap[int]boolas a lookup set built from one slice before scanning the other. - Write a program that groups a list of words into a
map[string][]stringkeyed by length category (“short” for 1–3 letters, “medium” for 4–6, “long” for 7+), then prints each category’s words sorted alphabetically. Hint: sinceappendcan return a new backing array, always reassign the result back into the map withm[key] = append(m[key], word).
Summary
- A map is Go’s built-in hash table: key-value storage with average O(1) lookup, insert, and delete.
- A map variable is a reference to shared underlying state; assigning or passing it copies the reference, not the data.
- The zero value of a map is
nil: safe to read, but writing to it panics — alwaysmake()or use a literal first. - Use the comma-ok idiom,
v, ok := m[key], to distinguish an absent key from one present with the zero value. - Map iteration order is randomized by design; sort keys yourself whenever order matters.
- Map keys must be comparable types; slices, maps, and functions cannot be keys, and two maps cannot be compared with
==. - Maps are not safe for concurrent use without external synchronization such as a mutex or
sync.Map.
