Maps: Comma-Ok, Random Order, and the Nil That Panics
Go's hash table, taught by its edges: the comma-ok idiom that separates absent from zero, iteration order that is deliberately randomized, reference semantics, the nil-map write that panics, and the empty-struct set idiom. Compiled and run against Go 1.26.5.
A map is Go’s built-in hash table: an unordered collection of key-value pairs with average O(1) lookup, insertion, and deletion. The basics are unremarkable and you’ll pick them up in a paragraph. What’s worth your attention is the cluster of behaviors around the edges, because three of them — the comma-ok read, the deliberately randomized iteration order, and the nil-map write that panics — bite people who assumed a map worked like the dictionary in whatever language they came from. This chapter is mostly those edges, run so you can see them.
Everything here is compiled and run against Go 1.26.5.
The basics, and comma-ok
You declare a map type as map[K]V, create one with make (or a literal), and index it with m[k]. The one twist is what happens when you read a key that isn’t there: instead of an error or an exception, you get the zero value of the value type. m["missing"] on a map[string]int returns 0, cleanly and silently.
That’s convenient, and it’s also a trap: 0 might be a real stored value or it might mean “absent,” and a bare read can’t tell you which. So map indexing has a two-value form, the comma-ok idiom, whose second result is a boolean that’s true only if the key was actually present.
package main
import "fmt"
func main() {
// map[K]V via make (or a literal).
inventory := make(map[string]int)
inventory["apples"] = 3
inventory["pears"] = 7
// Reading a missing key returns the zero value, no error.
fmt.Println("bananas:", inventory["bananas"]) // 0
// comma-ok distinguishes "present and zero" from "absent".
if v, ok := inventory["apples"]; ok {
fmt.Println("apples present, qty", v)
}
if v, ok := inventory["bananas"]; !ok {
fmt.Println("bananas absent, zero value was", v)
}
// delete removes a key (no-op if absent).
delete(inventory, "pears")
fmt.Println("len after delete:", len(inventory))
}
$ go run .
bananas: 0
apples present, qty 3
bananas absent, zero value was 0
len after delete: 1
The if v, ok := m[k]; ok shape is the if-with-init statement from the control-flow chapter, and it’s the canonical way to probe a map: ok tells you presence, v gives you the value in the same breath, and both are scoped to the if. Use it whenever “absent” and “present but zero” mean different things — which, for anything that can legitimately hold a zero, is most of the time. Deletion is the built-in delete(m, k), which quietly does nothing if the key is already gone, so you never guard it with a presence check.
Iteration order is randomized on purpose
Here’s the edge that surprises people the most. When you range over a map, the order is not defined, and Go actively randomizes it — the runtime picks a fresh starting point on each iteration. This is not “unspecified but stable in practice.” The order is genuinely randomized, so it varies run to run, and two loops over the same untouched map in the same program will often come out in different orders:
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
fmt.Print("pass 1: ")
for k := range m {
fmt.Print(k, " ")
}
fmt.Println()
fmt.Print("pass 2: ")
for k := range m {
fmt.Print(k, " ")
}
fmt.Println()
}
$ go run .
pass 1: e a b c d
pass 2: b c d e a
Same map, two loops, and here they came out in different orders in a single run. Because each range is randomized independently, two passes usually diverge like this — though on a map this small they’ll occasionally coincide, which is exactly why you must never treat either outcome as something to rely on. This was a deliberate design decision. Early Go had a stable-ish order, and people wrote code that quietly depended on it; the team then randomized iteration precisely to break that dependency loudly and early, rather than letting it fail mysteriously when the map implementation changed. The lesson is blunt: never rely on map order for anything. When you need a stable order, pull the keys into a slice and sort it — keys := make([]string, 0, len(m)), append each key, sort.Strings(keys), then range the slice. That extra step is the price of a hash table, and it’s cheap.
Maps are references
A map value is a small header holding a pointer to the runtime’s hash-table structure. So when you assign a map to another variable or pass it to a function, you copy that header — but both copies point at the same underlying table. Mutations through one are visible through the other, no pointer or return value required:
package main
import "fmt"
// The map value is copied into the parameter, but that copy points at the
// same underlying data — so mutations are visible to the caller.
func addOne(m map[string]int) {
m["count"]++
}
func main() {
m := map[string]int{"count": 0}
addOne(m)
addOne(m)
fmt.Println("count after two calls:", m["count"])
}
$ go run .
count after two calls: 2
addOne took the map by value, incremented a key, and the change stuck in the caller — both times. This is the same reference-like behavior slices have through their shared backing array, and it’s usually what you want: passing a big map around is cheap, and functions that mutate maps do so in place. The flip side is that it’s a shared mutable structure, so if two goroutines touch one concurrently you need a lock (a concurrent map write is a runtime fault, not something the race detector alone will always save you from). Concurrency is a later series; for now, just know that “passing a map makes a copy” is true only of the header, not the contents.
The nil map: reads fine, writes panic
A map’s zero value is nil. Like a nil slice, a nil map is safe to read — indexing returns the zero value, comma-ok returns ok == false, len is 0, and ranging does nothing. Unlike a nil slice, though, writing to a nil map panics at runtime:
package main
import "fmt"
func main() {
var m map[string]int // nil map, never made
// Reading a nil map is fine: zero value, ok=false.
fmt.Println("read from nil:", m["x"])
_, ok := m["x"]
fmt.Println("ok:", ok, "len:", len(m))
// Writing to a nil map panics.
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
m["x"] = 1 // panic
fmt.Println("never reached")
}
$ go run .
read from nil: 0
ok: false len: 0
recovered: assignment to entry in nil map
The reads all worked; the write blew up with assignment to entry in nil map, and only the deferred recover kept the program from crashing outright. This differs from slices, where append happily grows a nil slice — a nil map has no table to write into, and unlike append, a map assignment can’t return a new map to reassign, so there’s nowhere to lazily allocate one. The practical rule: make your map, or use a literal, before you write to it. The bug usually hides inside a struct — a field of map type is nil until you initialize it, so s.cache["k"] = v panics if nobody ran s.cache = make(...) first. When you see that panic message, you forgot a make.
Valid keys, and the set idiom
A map key can be any comparable type — one that supports ==. That covers booleans, numbers, strings, pointers, channels, interfaces, and structs or arrays built entirely from comparable fields. It rules out slices, maps, and functions, which are not comparable; try to use one as a key and the compiler stops you. Structs as keys are genuinely handy: a map[Point]string keyed on a two-int struct is perfectly legal and often cleaner than concatenating fields into a string.
That comparability rule powers the idiomatic set. Go has no built-in set type, so you build one from a map whose keys are your elements and whose values carry no information. The idiomatic value type is the empty struct, struct{}, because it occupies zero bytes — you’re paying only for the keys:
package main
import (
"fmt"
"unsafe"
)
func main() {
// A set: map[T]struct{}. struct{} is zero-width, so values cost nothing.
seen := make(map[string]struct{})
words := []string{"go", "is", "fun", "go", "is", "go"}
var unique []string
for _, w := range words {
if _, ok := seen[w]; !ok {
seen[w] = struct{}{} // the empty struct value
unique = append(unique, w)
}
}
fmt.Println("unique, first-seen order:", unique)
fmt.Println("set size:", len(seen))
// Size of the value type: zero bytes.
fmt.Printf("sizeof struct{}: %d bytes\n", unsafe.Sizeof(struct{}{}))
}
$ go run .
unique, first-seen order: [go is fun]
set size: 3
sizeof struct{}: 0 bytes
The pattern is map[T]struct{}, membership is a comma-ok read (_, ok := seen[w]), and adding an element is seen[w] = struct{}{} — that second {} is the empty struct’s one and only value. unsafe.Sizeof confirms it: the value type is genuinely zero bytes, so the set costs the same as the keys alone would. Some people use map[T]bool instead, which is a hair more readable (seen[w] = true) at the cost of a byte per entry and a slightly weaker signal — bool invites the question “what does false mean here?” that struct{} never raises. Both are common; struct{} is the one that says “this is a set, the value is nothing” out loud.
Final thoughts
Maps are map[K]V, made with make or a literal, read with m[k] or the comma-ok form that tells absent from zero. Their iteration order is randomized by design, so anything order-dependent needs a sorted slice of keys. They carry reference semantics — pass one to a function and mutations stick — so they’re cheap to share and dangerous to share across goroutines. Reading a nil map is fine but writing to one panics, which means make before you write, especially for map fields buried in structs. Keys must be comparable, which both restricts what you can key on and enables the map[T]struct{} set idiom where the zero-width empty struct makes the values free. Learn the edges and maps hold no more surprises.
Next: strings, bytes, and runes — why a string is a read-only byte slice, and what a range loop over one actually yields.
Comments