A map[K]V stores a value for each key, where the keys are decided while the program runs. To place a key it hashes the key down to a slot number and parks the value there; to look one up it hashes again and goes straight to that slot. Pick a key below and watch it land — then pick one that was never inserted and watch what comes back.
Level 1 showed a miss returning 0. That's not an error state — it's the rule: v := m[k] on an absent key hands back the zero value of V, silently. Which means the value alone cannot tell you whether a key was there. Level 2 is the form that can.
Here's the trap drawn out. Store one key, "Ghost", with a real value of 0. Then look up "Ghost" (present, value 0) and "Missing" (never inserted) with the single-value form. Both come back 0 — indistinguishable. Flip to the comma-ok form to separate them:
The mechanism: v, ok := m[k] returns a second value, ok, that is true exactly when the key was actually in the map. It's the only thing that distinguishes "stored, and its value happens to be the zero value" from "absent." Reach for it by default; the single-value v := m[k] is the shortcut you use only when you've already established the key is there (or genuinely don't care which case you're in). When you need only the yes/no and not the value, k in m is a plain bool: "Crim" in scores is true, "Missing" in scores is false.
The counterfactual: if your values are something a real entry could never be — say every stored score is at least 1, so a returned 0 must mean "absent" — then the single-value form is unambiguous and the bool buys you nothing. The comma-ok form earns its keep precisely when the zero value is a value a real entry might hold: a score of 0, an empty string, a nil handle. That's the case where reading the value alone silently lies.
Level 2 was the lookup gotcha. Level 3 is the resource bill: a map owns heap memory. The slots live in a buffer the map allocated, and nothing reclaims it for you — pairing creation with cleanup is your job, the same discipline as [dynamic]T.
You bring the table to life with make, and you put the cleanup on the very next line with defer delete so the two read as one unit. Removing a single entry is a different proc — delete_key(&m, k), with the & because a removal can resize the table — and len(m) reports the live entry count. Step through the lifecycle:
scores := make(map[string]int) defer delete(scores) // the whole table, freed at scope exit scores["Crim"] = 42 // insert delete_key(&scores, "Crim") // remove ONE entry — note the &
The bill, stated plainly: every make is a heap allocation you now own. No delete, and the buffer sits there until the process exits — a leak. The two procs are not interchangeable: delete(m) frees the entire table; delete_key(&m, k) removes one pair and leaves the table live. Mixing them up is how you either leak the whole map or accidentally tear it down.
The last level is a property that only bites at scale and over time: a map has no iteration order you can rely on. It's not random per se — it's unspecified, free to differ across runs, across grows, and across compiler versions. Lean on it and you've planted a bug that sprouts later.
The table reorders its slots whenever it grows (it rehashes every entry into a bigger buffer), so for k, v in m makes no promise about which key comes out first. If you print straight from that loop, the output is at the mercy of the hash layout. The fix is one idiom, and it's worth memorizing: collect the keys into a [dynamic]K, sort the slice, then iterate the sorted slice.
keys := make([dynamic]string, 0, len(scores)) defer delete(keys) for k in scores { append(&keys, k) } // order here is unspecified slice.sort(keys[:]) // now it's deterministic for k in keys { fmt.printfln("%q = %d", k, scores[k]) }
That sorted loop produces the same output every run — the deterministic lines below — which is exactly what you need for logs, golden tests, and reproducible gameplay. The unsorted loop might happen to match today and silently diverge after the table grows past a resize threshold.
The type system guards the front door, too. A key has to be hashable — its bits must form a stable identity. Most basic types qualify automatically, but a growable container can't: its contents and address move, so there's no fixed identity to hash. Try to declare a map keyed on one and the build fails, before the program ever runs:
m: map[[dynamic]int]int // a key whose identity isn't stable Error: Invalid type of a key for a map, got '[dynamic]int'
The emergent payoff: the two facts work together. Iteration order is unspecified because the map is free to rearrange itself for speed — and the moment you need a fixed order you reach for the collect-sort-iterate idiom rather than hoping. Meanwhile the hashable-key rule means a key that could silently change identity mid-flight is rejected at compile time, not discovered as a corrupt lookup at 2 a.m. The map trades away order to stay fast, and the type system makes sure the keys you hand it can actually be found again.
That's the arc: L1 hash the key to a slot, store a value per key → L2 a missing key returns the zero value silently, so v, ok := m[k] is the real read → L3 the map owns heap memory, so pair make with defer delete and prune with delete_key → L4 order is unspecified, so sort a key slice when you need it, and unhashable keys are refused at compile time.