Skip to content
academia.sh

Lesson 11 / 18

Maps

Reading from a map falls into four separate classes in four separate forms: an existing key gives its value, a missing key gives an absence answer along with the zero value, reading from a nil map is defined but writing to it panics, a deleted key behaves as though it never existed again. Iteration order is deliberately random and is never printed directly anywhere. The limiting measure shows why comma-ok access is necessary: a record whose value is zero and a record that never existed cannot be told apart with single-value access.

Published

In this lesson

  • Separates the class of four access forms on a map (present, missing, reading from nil, writing to nil)
  • Explains why a map's iteration order is never printed directly
  • Shows how comma-ok access tells apart a zero-valued record from absence
Contents

The previous lesson measured that whether sharing in slices continues or is cut off is read from capacity, not from the source code, and left a second question to be separated out: what happens when a key that does not exist is accessed in key-value matching, and does that answer arrive as a single value, or two? This lesson starts with that question.

The name for key-value matching in Go is map. Like a slice, a map is also a header — it carries not its own data, but a pointer leading to a hash table the runtime manages. But the answer a map gives to an unwritten key is more layered than the answer a slice gives to an unwritten bound: four separate access forms fall into four separate classes.

The source of this layering is that a slice is accessed only by position, while a map is accessed by key. Accessing an invalid position in a slice always does the same thing — an index that overruns the bounds panics. In a map, “key not present” is not a panic, because a map assumes that looking up a key that does not exist is a normal outcome, an ordinary query rather than an exhausted bound. A panic only comes when the map itself never existed at all (on a write attempt).

Four Access Forms

// maps.go — four access forms on a map, four separate classes
package main

import "fmt"

func panics(f func()) (p bool) {
	defer func() {
		if recover() != nil {
			p = true
		}
	}()
	f()
	return false
}

func main() {
	counter := map[string]int{"pen": 3, "notebook": 7, "eraser": 1}

	value, exists := counter["pen"]
	fmt.Println("existing key        : value=", value, " exists=", exists)

	value, exists = counter["manager"]
	fmt.Println("missing key         : value=", value, " exists=", exists)

	var emptyCounter map[string]int
	fmt.Println("reading a nil map   : value=", emptyCounter["anything"])

	writePanicked := panics(func() { emptyCounter["new"] = 1 })
	fmt.Println("writing to a nil map: panic=", writePanicked)

	delete(counter, "eraser")
	deletedValue, deletedExists := counter["eraser"]
	fmt.Println("deleted key         : value=", deletedValue, " exists=", deletedExists)
}
existing key        : value= 3  exists= true
missing key         : value= 0  exists= false
reading a nil map   : value= 0
writing to a nil map: panic= true
deleted key         : value= 0  exists= false

Five lines separate four classes. The notation value, exists := counter["pen"] in the source is named comma-ok access: reading from a map, if wanted, returns the value together with existence information instead of a single value. Reading from an existing key works as expected. A missing key ("manager", which never appears in counter) gives no error, it gives an absence answer along with int‘s zero value — this second answer is a separate section’s subject below. Reading from a map that was never given an initial value at all — emptyCounter, in the nil case — also works and again returns the zero value; in the Zero Values lesson’s binding 8/4/3 distribution, this line was one of the eight cases falling into the defined class. But writing to that same nil map falls into a different class: the language does not place a zero value here; the runtime panics — one of the same distribution’s three cases left open. The last line shows that a deleted key cannot be told apart, in behavior, from a key that never existed at all: after delete, counter["eraser"] gives the zero value and false, exactly like counter["manager"].

Three of the four access forms fit the same mold — reading is always safe, writing is not safe on a nil map. This asymmetry is not arbitrary: reading from a map only searches the hash table and returns the zero value if it does not find one, but writing requires adding an entry to the hash table, and a nil map does not yet have any hash table at all — the place to add to is not a zero-length backing array the way it is in a slice’s nil case, it is entirely absent. This asymmetry extends to delete as well: trying to delete a key that does not exist from a nil map also does not panic, because delete behaves like reading, not like writing — if it finds nothing to delete, it silently does nothing.

A Map Key Type Must Be Comparable

The Arrays and Slices lesson measured that an array can be compared with == and a slice cannot, and left this connected to usability as a map key in a single sentence. The map’s own lookup mechanism makes this connection mandatory: to find a key, a map compares it against existing keys through equality, and this is why the key type must be comparable.

CT9. — This constraint is run here with a compiler call; only the class “compiled” or “did not compile” is printed.

// key.go — a map key type must be comparable
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

func compiles(source string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}

func main() {
	arrayKey := `package trial
	func F() {
		m := map[[2]int]string{}
		m[[2]int{0, 0}] = "top-left"
	}`
	sliceKey := `package trial
	func F() {
		m := map[[]int]string{}
		_ = m
	}`
	fmt.Println("array-keyed map :", compiles(arrayKey))
	fmt.Println("slice-keyed map :", compiles(sliceKey))
}
array-keyed map : true
slice-keyed map : false

The array-keyed map compiles, because the array’s comparability was established in the previous lesson. The slice-keyed map does not compile — because a slice is not comparable, even the map itself cannot be defined, before a single entry is added. This shows how the same property (comparability) of two composite types produces results in two separate places: one in the == operator, the other in whether a type is accepted as a map’s key type.

Passing to a Function: A Map Behaves Differently From a Slice

The previous lesson measured that a function adding elements to the slice it receives with append never reaches the caller unless the result is returned. A map has no such trap.

// passing.go — writing to a map reaches the caller even without returning the result
package main

import "fmt"

func addSafely(m map[string]int, key string, value int) {
	m[key] = value
}

func main() {
	counter := map[string]int{"pen": 3}
	addSafely(counter, "eraser", 5)
	fmt.Println("map at the caller:", counter["eraser"])
}
map at the caller: 5

addSafely returns nothing, but counter["eraser"] is 5 when looked at from the caller. The difference comes from a slice’s append and a map’s write operation doing different things: when append exceeds capacity, it allocates a new backing array and updates the header — this update happens only in the local copy. Writing to a map, by contrast, changes the existing hash table in place; the header itself never changes, only the table’s content changes. Because the map variable passed as a parameter points to the same table as the one at the caller, every change made to the table is visible to the caller. This is exactly where a map departs from a slice: a slice sometimes has to change its backing array as it grows, while a map never changes its own table as it grows — it only expands it.

Capacity Is a Hint, Not a Capacity Limit

The second argument that can be given when building a map with make does not carry the same promise as capacity in a slice. In a slice, capacity was a limit — once exceeded, append would allocate a new backing array. In a map, that same number is only a hint; it sets the hash table’s starting size but does not limit the map’s growth in any way.

// hint.go — a map's capacity is a hint, not a limit
package main

import "fmt"

func main() {
	small := make(map[string]int, 2) // only a starting hint
	for i, name := range []string{"a", "b", "c", "d", "e", "f"} {
		small[name] = i
	}
	fmt.Println("initial hint=2, keys added:", len(small))
}
initial hint=2, keys added: 6

small was built with a hint allocated for only two keys, but six keys were added without a problem. Adding a third element to a slice built with the notation make([]int, 0, 2) would force append to allocate a new backing array, and this would lead to the sharing cut-off measured in the Slice Operations lesson; a map has no such threshold to cross, because the second argument does not commit to a capacity, it only gives the runtime an estimate. A map’s own internal table grows when it needs to, but this growth does not produce a header change visible to the caller the way a slice’s append does — because a map’s header is already the single header shared with the caller, it has no second copy to be reassigned.

Iteration Order: A Defined Uncertainty

The order of iterating over a map with range is deliberately random; the same map, even within the same program, can be walked in a different order across two separate range calls. This is not an implementation flaw, it is a defined uncertainty: the language deliberately shuffles the order so that iteration order never becomes something any code depends on. This is why no lesson in the course prints a map directly; keys are sorted instead.

// iteration.go — a map is never printed directly, its keys are sorted first
package main

import (
	"fmt"
	"sort"
)

func main() {
	counter := map[string]int{"pen": 3, "notebook": 7, "eraser": 1}
	keys := make([]string, 0, len(counter))
	for k := range counter {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Println(" ", k, "=", counter[k])
	}
}
  eraser = 1
  notebook = 7
  pen = 3

The order printed here is alphabetical, because what produces it is the sort.Strings call — not the map’s own iteration order. The loop for k := range counter only gathers the keys into a slice; the order in which it gathers them is irrelevant to this measurement, because sort.Strings brings the result to the same order on every run.

The purpose of deliberately keeping the order random is to prevent code from becoming dependent on order from the start. Had the order been consistent — for instance, always following insertion order — code relying on that consistency would accumulate over time: code that formats a loop’s output according to insertion order, or that walks two maps in the same order to compare them. Such code would break silently the moment the hash table’s internal structure changed (for instance, when a key is deleted and re-added). Keeping the order random from the start guarantees that this dependency never forms — a programmer cannot rely on order, because there is no fixed order to rely on.

Printing a map directly with fmt.Println(counter) also works technically, and the formatting package can even produce output sorted by key — but this is the formatting package’s own choice, not the language’s guarantee; the same guarantee does not exist when iterating directly with range. The measurement does not rely on this difference and does its own sorting by hand; no lesson in the course prints a map by relying on range’s output.

CT10. — Sorting is done here on a small set of three keys; the measurement’s goal is not to show sorting itself, it is to show that the map’s own order is never relied on at all. The same result would also be obtained with hundreds of keys, only the output would be longer.

Limiting Measure: A Zero Value and Absence Cannot Be Told Apart With a Single Value

Why comma-ok access exists is not fully understood without seeing where single-value access falls short. When a key’s value is the same as the type’s zero value, a map can no longer tell two different situations apart: the key exists and its value is zero, or the key does not exist at all.

// ambiguity.go — a zero-valued record and absence cannot be told apart with a single value
package main

import "fmt"

func main() {
	counter := map[string]int{"pen": 3}
	counter["new-product"] = 0 // record exists, value is deliberately zero
	singleValue := counter["new-product"]
	singleValueMissing := counter["never-added"]
	fmt.Println("present, value is zero  : single value=", singleValue)
	fmt.Println("never added             : single value=", singleValueMissing)

	_, exists1 := counter["new-product"]
	_, exists2 := counter["never-added"]
	fmt.Println("two-value distinction: new-product exists=", exists1, " never-added exists=", exists2)
}
present, value is zero  : single value= 0
never added             : single value= 0
two-value distinction: new-product exists= true  never-added exists= false

The first two lines are exactly the same: 0. Looked at from single-value access, there is no difference at all between "new-product" and "never-added" — both give zero. The third line removes this blindness: exists1 is true, exists2 is false. The difference is not in the value itself, it is in whether the map holds that key at all.

This limitation produces real consequences in practice. If a counter map distinguishes “never seen” from “seen but incremented zero times,” or a configuration map distinguishes “took the default” from “explicitly set to zero,” single-value access can never see this distinction at all. Comma-ok access here is not merely a required notation, it becomes the only correct notation — being “defined,” on its own, does not mean being “sufficient.”

This limitation requires rereading the lesson’s opening four classes as well. The two lines in the “missing key” and “deleted key” sections gave the same 0 when looked at with a single value; before comma-ok access was added, it was not visible that these two — and a third key whose value is genuinely zero — could not be told apart from each other. The single answer a map gives does not carry the difference between “a value exists” and “no value exists, zero was put in its place”; what carries it is the second return value.

Summary

  • Four access forms on a map fall into four separate classes: an existing key gives its value, a missing key gives an absence answer with the zero value, reading from a nil map is defined but writing to it panics, a deleted key behaves as though it never existed again.
  • Writing to a map changes the existing table in place; a write made to a map passed to a function reaches the caller even without returning the result — different from a slice’s append behavior.
  • A map’s iteration order is deliberately random and is never printed directly in any lesson; keys are sorted with sort.Strings and printed that way.
  • Comma-ok access is the only way to tell a zero-valued record apart from absence; single-value access returns both with the same zero value.

Next Step

A map’s key was an indivisible value like a single integer or string; it carried a single piece of information, as in the counter example. The next type holds several fields together, and each of its own fields can be accessed separately — where a map is accessed with a single key, this new type is accessed with several named fields. A struct also starts with something unwritten: what happens if its fields are not given a value? But unlike a map, the fields’ order also carries meaning, and relying on that order has its own fragility — where a map is accessed by key name, this new type carries a field where accessing by position is also an option.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close