skip to content

While ranging over a Go map, what happens to entries you delete or add mid-loop?

level: seniorimportance: should knowfreq 42%

answer

  1. the spec says one thing about each direction
  2. removal is honoured, creation is not
  3. no snapshot, no invalidation, no panic
  4. collect first, apply after the loop

basics

~20 s

Deleting is safe and defined: an entry removed before the loop reaches it is never produced, and removing the current key is fine. Adding is not defined: a key created during the loop may or may not be produced, and the choice can differ per entry and per run.

solid answer

~50 s

Go specifies both cases, and they differ. Deleting during `for k := range m` is legal and deterministic in the only way that matters: an entry removed before the iteration reaches it will not be produced, and deleting the key you are currently on is fine. That makes in-place filtering — `for k := range m { if drop(k) { delete(m, k) } }` — the idiomatic way to prune a map, with no invalidation and no snapshot. Inserting is the opposite: an entry created during the iteration *may or may not* be produced, and the spec explicitly says the choice may vary for each entry and from one run to the next. Because Go also randomises where a range starts, that non-determinism is not theoretical — it is the classic "passes locally, fails in CI" bug. If a loop must act on entries it derives, collect them into a slice or a second map and apply them after the range ends.

code

go · 6 lines
go
// index is map[string][]int
for term, docs := range index {
	if len(docs) == 0 {
		delete(index, term) // safe, including for the current key
	}
}

go deeper

for a junior

Know that you may delete from a map while ranging over it without any special ceremony, and that adding new keys inside the loop is something to avoid.

for a middle

State the two rules precisely: an entry removed before it is reached is not produced, an entry created during the range may or may not be. Explain why that makes the in-place filter loop safe.

for a senior

Recognise this as a cause of intermittent test failures, separate it from a data race in triage, and refactor to the collect-then-apply shape rather than papering over it with retries or sorted output.

for a principal

Decide the team's stance on unspecified behaviour: code whose result depends on iteration order should be caught in review, not by a flake budget, and any helper that mutates a map it iterates deserves an explicit contract.

## The two rules Ranging over a map is defined loosely on purpose, but not vaguely. The specification makes exactly two statements about mutation during a `for ... range` over a map: 1. **If an entry that has not yet been reached is removed during the iteration, it will not be produced.** Deletion is honoured. 2. **If an entry is created during the iteration, it may be produced or it may be skipped, and the choice may vary for each entry created and from one iteration to the next.** Insertion is unspecified. Neither operation invalidates the loop. There is no snapshot taken at the top, no fail-fast check, and no panic analogous to the concurrent-modification exceptions that other languages raise. A single goroutine may mutate the map it is ranging over; the only question is what the loop then shows you. ## Why deletion is the useful half Because removal is honoured, the in-place filter is a standard Go idiom: ```go for term, docs := range index { if len(docs) == 0 { delete(index, term) } } ``` This is safe whether you delete the current key or some other key, and `len(index)` drops as you go. You do not need to collect keys first and delete afterwards — that pattern is a habit carried in from languages whose iterators invalidate, and in Go it just costs an allocation. ## Why insertion is the dangerous half Consider a set of terms where the loop derives a new term from each one it sees: ```go seen := map[string]struct{}{"go": {}, "maps": {}} for term := range seen { seen[term+"s"] = struct{}{} // may or may not be produced by this same range } ``` How many iterations run is not specified. If a newly created entry happens to land where the iteration has not yet been, it may be produced — and then it derives another entry, and so on. The loop can terminate after two iterations, after four, or after a number that differs between runs of the same binary. Nothing here is a race and nothing is undefined behaviour; the language simply declines to promise which entries a range sees. Go compounds this deliberately: the starting position of a map range is randomised, so two ranges over the same unmodified map visit entries in different orders. That randomisation exists to stop code from depending on an order the implementation never promised, and it has the side effect of making insertion-during-range non-deterministic *in practice*, not merely in theory. ## How this reaches you as a flaky build The realistic bug report is a test that fails on one CI run in twenty. A test builds a small map, runs a function that ranges over it and inserts derived entries, and asserts on the resulting size or on a collected slice. Locally, and on nineteen runs, the derived entries happen to land behind the iteration cursor and are skipped; on the twentieth, one lands ahead and is visited, producing an extra entry and a different count. Nothing about the machine, the load or the scheduler changed — the range simply started somewhere else. When triaging that shape of flake, the tell is a `range` over a map whose body writes to the same map with a *new* key. Two things separate it from a genuine race: it reproduces under `go test -count=100` on one goroutine, and it is not reported by the race detector, because there is no concurrent access to detect. The fix is structural, not statistical: never retry, never sort your way around it. ## The correct pattern Separate reading from writing: ```go var derived []string for term := range seen { derived = append(derived, term+"s") } for _, term := range derived { seen[term] = struct{}{} } ``` The range is now over a stable key set, the number of iterations is exactly `len(seen)` at entry, and the result does not depend on where the iteration started. The same shape applies when the derived work is expensive: collect into a slice, close the loop, then apply. ## One important non-case Updating the value of a key that already exists is **not** entry creation, so it is fully defined. `m[k] = newValue` inside a range over `m` changes nothing about the key set and cannot affect which entries the loop produces. Note only that the loop's own value variable was copied when the entry was produced, so an update to the current key is not reflected in the `v` you already hold. ## What interviewers listen for The asymmetry, stated the right way round: deletion honoured, insertion unspecified. Bonus points for knowing that it never panics, that updating an existing key is safe, and for recognising the failure as a source of intermittent test failures rather than as a concurrency bug.

  • Is it safe to delete the key the loop is currently on?
    Yes. Deletion during a range is legal for any key, including the current one; the iteration is not invalidated and nothing panics. The only rule is about entries not yet reached, which are simply not produced once removed. That is what makes the in-place filter loop idiomatic.
  • Is updating the value of an existing key during a range also unspecified?
    No — that is not entry creation, so the key set is unchanged and the loop is unaffected. The one caveat is that the loop's value variable holds a copy made when the entry was produced, so updating the current key does not change the `v` you already have in hand.
  • A test that ranges over a map fails on one CI run in twenty. How do you tell this bug from a data race?
    Run it single-goroutine with `go test -count=100`: this failure reproduces, a race usually needs concurrency. The race detector stays silent here because there is no concurrent access — one goroutine inserting into the map it is ranging over is legal, just unspecified in what the loop sees. Look for a `range` whose body writes a new key to the same map.

saying these in an interview costs you the question

  • Expects a concurrent-modification panic when deleting during a range
  • Assumes a key inserted during the loop is always visited
  • Assumes a key inserted during the loop is never visited
  • Believes range takes a snapshot of the map first
  • Diagnoses the resulting flaky test as a data race