Skip to content
beginner Phase 5 · Arrays, Slices, Maps, and Iteration

Maps: Insert, Lookup, and Delete

Use map literals, comma-ok lookups, delete, and range with randomized order.

40m
0 problems
Topic Progress 0%

The Lookup That Admits What It Doesn't Know

A moderation endpoint checks whether a client IP is banned before serving a request. The obvious write-up fails silently:

banned := map[string]string{
	"203.0.113.9": "spamming signup",
}

reason := banned[ip]          // "" when absent
if reason != "" {             // treats "not banned" and "banned with empty reason" as one case
	reject(reason)
}

A single-value map read returns the zero value of the value type when the key is missing. For strings, "absent" and "present but empty" are indistinguishable — and the zero value leaks into every type: 0, false, "". The language gives you a second form that separates existence from content:

reason, ok := banned[ip]
if ok {
	reject(reason) // key exists; reason is the stored value
}
// ip not in the map at all

That is the comma-ok idiom, and it is the default lookup whenever absence is meaningful. Where it matters most is value types whose zero is a legitimate stored value: a map[string]int hit count of 0 (key present, counter never incremented) is a different fact from "no hits recorded", and only ok can tell them apart.

hits, recorded := counters[path]
if !recorded {
	hits = 1 // first sighting
} else {
	hits++
}
counters[path] = hits

Both halves appear constantly in Go code: ok == false to initialize, ok == true to update. Feature flags show the same shape with booleans, where the single-value form is actively misleading:

flags := map[string]bool{"beta": false} // explicitly disabled

if on, ok := flags[feature]; !ok {
	on = defaultFor(feature) // nobody configured it: apply policy
}
if !flags[feature] { // conflates "disabled" with "never mentioned"
	...
}

Disabled-by-a-human and never-configured are different facts with different answers, and only ok keeps them apart. Comma-ok also composes: helpers can return (*T, bool) to expose presence without inventing sentinel values, and it is the reason map-like lookups in the standard library (time.Time lookup, cache APIs) pair a value with a flag instead of overloading a zero value.

Reading a missing key never panics; writing to a nil map does, which is a separate rule covered further down.

Insert, Count, Delete: The Four Operations You Actually Use

Map mutation has no method calls — insert, update, and upsert are all bracket assignments. Once the index expression appears on the left, the operation is a write, and the key is inserted if it was not there:

func tally(words []string) map[string]int {
	counts := make(map[string]int, len(words)) // size hint: one bucket region for n keys
	for _, w := range words {
		counts[w]++ // missing key reads as 0, then the ++ writes 1 back
	}
	return counts
}

counts[w]++ works because the compiler rewrites it as counts[w] = counts[w] + 1: the read of a missing key yields 0, and the assignment inserts the result. This is the counting idiom — no if, no ok. Reach for comma-ok only when the stored zero itself is meaningful, as in the hits example before.

Deletion is one builtin with two properties worth internalizing:

delete(counts, "the")   // removes the key if present
delete(counts, "zzz")   // no-op if absent — never panics
fmt.Println(len(counts)) // shrinks immediately

delete never errors, so cleanup code can call it unconditionally. len counts keys, not buckets, so it stays accurate after deletes.

Two allocation habits keep hot maps fast. First, make(map[K]V, n) takes a size hint — it does not fix capacity forever like a slice's third argument, it pre-reserves bucket space for roughly n entries so a loop inserting n keys avoids repeated bucket growth. Second, reuse a map by clearing keys in a loop instead of reassigning a fresh one when the map lives inside a hot function:

var scratch = map[string]float64{}

func average(prices []float64) float64 {
	for k := range scratch {
		delete(scratch, k) // keeps bucket storage, drops entries
	}
	// ... fill scratch ...
}

Deleting during range is legal and is the standard way to filter a map in place, which pairs directly with the iteration rules coming next.

range Gives You Keys in No Particular Order

A report generator iterates a map[string]float64 of regional sales and prints rows expecting alphabetical order. On one run the output looks sorted; on the next it is scrambled. The program is not racy — the specification simply does not define an order for map iteration, and Go deliberately randomizes where iteration starts so that code accidentally depending on order fails visibly instead of subtly.

sales := map[string]float64{"north": 1200, "east": 900, "west": 1450}

for region, total := range sales {
	fmt.Printf("%-6s %.0f\n", region, total) // any order, varies run to run
}

When order is a requirement — reports, deterministic tests, diff-friendly logs — collect the keys and sort them:

regions := make([]string, 0, len(sales))
for r := range sales {
	regions = append(regions, r)
}
sort.Strings(regions)
for _, r := range regions {
	fmt.Printf("%-6s %.0f\n", r, sales[r])
}

The capacity of regions comes straight from len(sales), so the collection step allocates once.

Iteration rules that bite in production code:

  • Deletes during range are safe. The current key's entry is gone; other keys are still visited. This makes delete(m, k) inside a conditional the idiomatic in-place filter.
  • Inserts during range may or may not be visited. A key added mid-iteration has no guarantee of appearing in that same loop — never use range-as-queue.
  • Each pre-existing key is visited once. No duplicates for keys that existed when the loop started.
for host, fail := range failures {
	if fail > max {
		delete(failures, host) // purge unhealthy hosts while ranging — allowed
	}
}

If a function's correctness would break under a shuffled order — think of anything picking "the first match" — find the bug by sorting keys in a test. Order-dependent logic on maps is one of the few Go mistakes the runtime will not warn you about.

Reads Are Free, Writes Need a Home, Sharing Needs a Guard

A map's zero value is usable for every read and unusable for every write. Knowing which half you are on prevents both the panic and the boilerplate:

var lookup map[string]string // nil

fmt.Println(len(lookup))     // 0
v, ok := lookup["x"]         // "", false — reads are fine
for k := range lookup {}     // zero iterations, no panic

lookup["x"] = "boom"         // fatal: assignment to entry in nil map

The asymmetry exists because a nil map has no bucket storage to write into, while reads can answer "missing" without storage. Contrast the slice, where both directions work on nil (Slice Basics and the Slice Header) — the difference is structural, not stylistic.

That leads to a design choice every struct with a map field makes once:

// Option A: zero value works for reads, lazily init before first write.
func (c *Cache) Put(k, v string) {
	if c.data == nil {
		c.data = map[string]string{}
	}
	c.data[k] = v
}

// Option B: constructor owns initialization; readers must not assume nil.
func NewCache() *Cache {
	return &Cache{data: make(map[string]string, 64)}
}

Option A serves the "zero value is useful" ideal (Nil Checks and Zero-Value Design); option B centralizes initialization. Pick one per type and document it — mixing both in one struct is how nil-map panics survive code review.

The second rule is non-negotiable: a map must not be written from two goroutines, or read while another goroutine writes it. The runtime detects it and terminates the program with fatal error: concurrent map writes — a fatal, not a recoverable panic, so recover cannot save the process. The fixes are structural: guard with a sync.Mutex or sync.RWMutex (Mutexes and Atomics), or confine the map to one goroutine and pass messages. Copying a map for reads scales worse than a lock for most workloads; reach for sync.Map only with evidence of its specific access pattern, not as a default.

Quiz

1. counters is map[string]int. The key "GET /health" has never been written. What do v, ok := counters["GET /health"] return?

Question 1 options

2. A test iterates a map of usernames and asserts results appear in sorted order. It passes locally and fails in CI. What is happening?

Question 2 options

3. var m map[string]int is declared and never initialized. Which line panics?

Question 3 options

4. Two goroutines run totals[key]++ on a shared map without synchronization. What is the worst-case outcome?

Question 4 options

Flashcards

Question

When does a single-value map lookup suffice, and when do you need comma-ok?

Answer

Single-value lookup is fine when the zero value genuinely means 'nothing here' — like a missing word count reading 0. Comma-ok is required when presence itself is information: distinguishing a stored empty string from an absent key, a stored 0 from 'never seen', or a false flag from 'not configured'.

Question

Why does counts[word]++ work without checking whether word exists?

Answer

The compiler rewrites it as counts[word] = counts[word] + 1. The read of a missing key yields the value type's zero (0), and the assignment inserts the result — so the first sighting becomes 1 with no branch.

Question

What are the exact semantics of deleting a key that was never in the map, and deleting during range?

Answer

delete(m, k) on a missing key is a silent no-op — it never errors. Deleting keys you are currently ranging over is explicitly allowed, which makes conditional delete the idiomatic way to filter a map in place; inserts during range, by contrast, may or may not be visited.

Question

Why does make(map[K]V, n) matter in a loop that inserts n entries?

Answer

The second argument is a size hint that pre-reserves bucket space for about n keys, so the loop avoids repeated rehashing as the map grows. Unlike a slice's capacity argument it is a starting estimate, not a hard limit — the map still grows past it.

Question

Your service crashes with 'fatal error: concurrent map writes' and a recover() somewhere up the stack does nothing. Why?

Answer

It is a runtime fatal, not a panic: the runtime halts the process when it detects unsynchronized map mutation, and recover only catches panics. The fix is upstream — protect the map with a mutex, use channels to funnel writes to a single goroutine, or give each goroutine its own map and merge.

Revision Notes

Key Takeaways

  • 1. Comma-ok separates 'key missing' from 'key holds the zero value' — use it whenever presence is itself information
  • 2. m[k]++ is the counting idiom because missing keys read as zero and the assignment inserts the result
  • 3. Map iteration order is unspecified by design; sort collected keys when output or assertions need determinism
  • 4. nil maps support len, range, and delete but panic on first write — choose lazy init or a constructor per type, not both
  • 5. Maps are not safe for concurrent writes: guard with a mutex or confine ownership to one goroutine

Interview Tips

  • • "How do you check if a key exists?" — write the two-value form immediately, then explain why the single-value form loses information
  • • Asked for word frequency: reach for make(map[string]int, len(words)) plus counts[w]++, showing both the idiom and the size hint
  • • "Why is my map output order unstable?" — say order is unspecified and randomized on purpose, then show the collect-and-sort fix
  • • Flag unsynchronized shared maps proactively in concurrency questions: 'map writes from multiple goroutines are a fatal, so the design needs a mutex or single-owner goroutine'

Cheat Sheet

Maps: Insert, Lookup, Delete

Operation table:

Task Idiom
read (zero is fine) v := m[k]
read (presence matters) v, ok := m[k]
insert / upsert m[k] = v
count occurrences m[k]++
delete (safe if absent) delete(m, k)
size len(m)
pre-size before a fill loop make(map[K]V, n)
stable order collect keys -> sort -> index

Code template:

func TopWord(words []string, n int) []string {
	counts := make(map[string]int, len(words))
	for _, w := range words {
		counts[w]++
	}
	keys := make([]string, 0, len(counts))
	for w := range counts {
		keys = append(keys, w)
	}
	sort.Slice(keys, func(i, j int) bool {
		if counts[keys[i]] != counts[keys[j]] {
			return counts[keys[i]] > counts[keys[j]]
		}
		return keys[i] < keys[j] // tie-break keeps output deterministic
	})
	if len(keys) > n {
		keys = keys[:n]
	}
	return keys
}

Hard rules: range order is unspecified (randomized) — sort when order matters; nil map reads fine, writes panic; concurrent write without a lock is a fatal, not a recoverable panic; delete never fails.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading