---
title: Maps
source: 'https://academia.sh/en/courses/go-fundamentals/maps'
course: 'Go Fundamentals'
language: en
updated: '2026-09-13T05:08:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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

```go
// 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.

```go
// 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.

```go
// 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.

```go
// 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.

```go
// 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.

```go
// 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.
