skip to content

Several goroutines assign to an `err` variable declared in the enclosing function — why is that a race?

level: middleimportance: should knowfreq 56%

answer

  1. closures capture variables, not values
  2. local does not mean unshared
  3. one storage location, many writers
  4. the compiler moves it to the heap
  5. give each goroutine something it owns

basics

~20 s

A Go closure captures the variable itself, not a copy of its value. Every goroutine writes the same storage, so concurrent assignments to that one err variable are an unsynchronized data race exactly like writes to a shared global.

solid answer

~50 s

Closures in Go capture **variables**, not values. When several goroutine literals refer to an `err` declared in the enclosing function, they all refer to one storage location — the compiler sees that it outlives the frame and moves it to the heap. Concurrent assignments to it are therefore an ordinary data race: the variable being spelled as a local says nothing about how many goroutines can reach it. `error` is an interface, so the write is two words, which means a racing reader can even see a mixed value rather than one of the two errors. The usual fixes are to give each goroutine its own result and send it out on a buffered channel the caller drains, to collect under a `sync.Mutex`, or to keep one error per index in a pre-sized slice where each goroutine writes only its own element.

code

go · 17 lines
go
func applyAll(ids []string) error {
	var err error // one variable, captured by every closure below
	done := make(chan struct{})

	for _, id := range ids {
		go func(id string) {
			if e := apply(id); e != nil {
				err = e // concurrent unsynchronized write: a data race
			}
			done <- struct{}{}
		}(id)
	}
	for range ids {
		<-done
	}
	return err
}

go deeper

for a junior

Remember the rule that a closure captures the variable itself, so a local mentioned inside a goroutine literal is shared memory. Say that concurrent assignments to it need a lock or a redesign.

for a middle

Explain that the captured variable escapes to the heap and is one location for all the goroutines, and that error being an interface makes the write two words. Offer at least two fixes and say why you prefer removing the sharing.

for a senior

Catch this shape in review before it ships, and steer the design toward per-goroutine ownership — a slot per index or a buffered result channel — rather than adding a mutex around an accumulator with no defined winner. Say what should happen to the other goroutines when one fails.

for a principal

Set the house pattern for fan-out work: how results and errors come back, whether the first failure cancels the rest, and what reviewers reject outright, so every team is not reinventing an accumulator that races.

## What a closure actually captures A function literal in Go that mentions a variable from an enclosing scope captures **the variable**, not a snapshot of its value at the moment the literal is created. Every closure that mentions `err` and every subsequent read or write of `err` in the outer function refer to the same storage. Because the goroutines can outlive the frame that declared the variable, the compiler's escape analysis moves it to the heap; the code still reads `err`, but the memory is now shared, long-lived state. That is the whole surprise. Engineers apply a mental rule — "locals are private, globals are shared" — that holds in single-threaded code and breaks the moment a goroutine literal closes over the local. A captured local is exactly as shared as a package-level variable, and needs exactly the same discipline. ## The shared-accumulator shape The pattern that produces this bug is almost always an accumulator: ```go var err error for _, id := range ids { go func(id string) { if e := apply(id); e != nil { err = e // every goroutine writes the same location } }(id) } ``` The author's intention is "if anything failed, leave an error behind", and it reads as harmless because only failing goroutines assign. It is a data race all the same: two goroutines can execute that assignment at the same instant with nothing ordering them. Note that passing `id` as a parameter — the right habit for the value being iterated — does nothing for `err`, because `err` is not a parameter; it is still the one captured variable. There is a second consequence that people miss. `error` is an **interface**, so the assignment stores two words: the dynamic type and the data pointer. A racing observer can see a mixture of two different concurrent stores, i.e. one error's type paired with another's value. So even the sloppy justification "whichever error wins, I get a valid error" is not guaranteed. ## Where the read is, and is not, a problem The outer function usually reads `err` after it knows the goroutines are finished — after draining a done channel, for example. That final read is ordered with respect to the goroutines' completion and is not the interesting part. **The race is between the goroutines' own writes**, and it exists whether or not the caller's read is properly ordered. Candidates who only think about the read miss the bug entirely. ## Fixes that actually work **One result per goroutine, over a channel.** Each goroutine produces its own value and sends it; nothing is shared: ```go errs := make(chan error, len(ids)) for _, id := range ids { go func(id string) { errs <- apply(id) }(id) } var all []error for range ids { all = append(all, <-errs) } return errors.Join(all...) // errors.Join is Go 1.20+ ``` The channel is buffered to the number of senders so no goroutine blocks if the caller returns early — that turns a race bug into a leak bug otherwise. **One slot per goroutine in a pre-sized slice.** `results := make([]error, len(ids))` and goroutine *i* writes only `results[i]`. Distinct elements of a slice are distinct memory locations, so there is no race and no lock. This is the cheapest fix when the count is known up front. It is only correct if no goroutine appends or reslices — that would mutate the shared header. **A mutex around the accumulator.** Keep the shared `err` but take a `sync.Mutex` for every assignment. It works and is sometimes the smallest diff, but it hides the fact that the code has no defined winner among several errors. **Cancellation as a bonus.** If the point of recording the first error is to stop the rest of the work, the shape you want is a `context.Context` the goroutines watch, so the first failure cancels the others rather than merely being remembered. ## What to say in review The reviewable rule is short: *a variable captured by a goroutine literal is shared state*. If more than one goroutine can assign to it, it needs a lock, an atomic, or a redesign in which each goroutine writes only something it owns. The best version of the fix removes the sharing rather than protecting it, which is why the per-goroutine channel or the per-index slot is preferred over bolting on a mutex.

  • Passing a value as an argument to the goroutine's function literal — what does that actually fix?
    It makes a copy at the moment of the `go` statement, so each goroutine has its own parameter and no longer reads a variable someone else may change. That fixes shared *reads* of a changing value. It does nothing for a shared write target: assigning to a captured accumulator from several goroutines is still one location with many writers.
  • Is the caller's read of err after all goroutines have finished also racy?
    That read is fine as long as the caller learns of completion through a real synchronization step, such as draining a channel every goroutine sends on. The race being asked about is between the goroutines' own concurrent assignments, and it exists regardless of how well the final read is ordered — fixing the read does not fix the writes.
  • How would you collect one error per goroutine with no shared variable at all?
    Two clean options. Give each goroutine a slot it owns: `results := make([]error, len(ids))` and goroutine i writes only `results[i]` — distinct elements are distinct locations, so no lock is needed. Or have each goroutine send its own error on a channel buffered to the number of senders, and let the caller drain and combine them with `errors.Join`.

saying these in an interview costs you the question

  • It is a local variable, so it cannot be shared
  • Assigning an error value is a single atomic store
  • Last writer wins, which is good enough here
  • Only the loop variable needs care in a goroutine
  • Buffering the done channel removes the write race
  • Only failing goroutines assign, so collisions are impossible