Go Closures — Find the Bug¶
Instructions¶
Each exercise contains buggy Go code involving closures or capture. Identify the bug, explain why, and provide the corrected code. Difficulty: 🟢 Easy, 🟡 Medium, 🔴 Hard.
Bug 1 🟢 — Loop Variable Capture (Pre 1.22)¶
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
(go.mod: go 1.21.) What's printed?
Solution
**Bug**: All goroutines capture the SAME `i`. By the time most run, `i == 5`. Output (in pre-1.22): **Fix** (option A — pass as argument): **Fix** (option B — shadow): **Fix** (option C — upgrade `go.mod` to `go 1.22`+). **Key lesson**: Pre-1.22 loop variables are shared. Pass as arg or shadow.Bug 2 🟢 — Recursion-By-Name¶
package main
import "fmt"
func main() {
fact := func(n int) int {
if n <= 1 { return 1 }
return n * fact(n-1)
}
fmt.Println(fact(5))
}
Solution
**Bug**: `fact` is not yet declared when the literal references it. **Compile error**: `undefined: fact`. **Fix**: The captured variable `fact` is set BEFORE the closure is called. **Key lesson**: Closures can recurse via captured variables. Declare with `var` first.Bug 3 🟢 — Capture by Reference (Surprise)¶
package main
import "fmt"
func main() {
x := 1
f := func() int { return x }
x = 99
fmt.Println(f())
}
The author expected 1. What prints?
Solution
**Bug**: Closures capture by REFERENCE. `x` is the same variable inside and outside. After `x = 99`, the closure sees 99. Output: **Fix** — for snapshot capture, shadow with `x := x` inside: Or pass as argument: **Key lesson**: Default capture is by reference. Use shadow for snapshots.Bug 4 🟢 — Concurrent Mutation Without Lock¶
package main
import (
"fmt"
"sync"
)
func newCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
c := newCounter()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c()
}()
}
wg.Wait()
fmt.Println(c())
}
What's the issue?
Solution
**Bug**: Captured `n` is modified concurrently from 1000 goroutines without synchronization. Data race; final value may not be 1001. `go run -race main.go` flags it: WARNING DATA RACE. **Fix** — synchronize: Or use atomic: **Key lesson**: Captured mutable state shared across goroutines requires synchronization, like any shared variable.Bug 5 🟡 — Heavy Capture Pinning Memory¶
package main
import "fmt"
type BigData struct{ buf [1 << 20]byte }
func makeReader(b *BigData) func() byte {
return func() byte {
return b.buf[0]
}
}
func main() {
var fns []func() byte
for i := 0; i < 100; i++ {
b := &BigData{}
fns = append(fns, makeReader(b))
}
fmt.Println(len(fns)) // 100
// Each closure pins 1 MB; total ~100 MB
}
Solution
**Bug**: Each closure captures `b` (the `*BigData`). The 100 BigData instances stay alive as long as their closures exist. **Fix** — capture only what you need: Now each closure captures 1 byte. The BigData instances are reclaimable as soon as `makeReader` returns. **Key lesson**: Closures pin captured pointers. Extract minimum data.Bug 6 🟡 — Snapshot via i := i Misplaced¶
package main
import "fmt"
func main() {
fns := []func() int{}
i := 0
for i = 0; i < 3; i++ {
fns = append(fns, func() int {
i := i // BUG?
return i
})
}
for _, f := range fns {
fmt.Println(f())
}
}
Solution
**Discussion**: This is interesting. The shadow `i := i` is INSIDE the closure body — it runs each time the closure is called, capturing the CURRENT value of the outer `i`. After the loop, the outer `i == 3`. So when the closures run later, they all read 3. Output: **Fix** — shadow OUTSIDE the literal, INSIDE the loop body: Now each closure captures a distinct `i`. **Key lesson**: The shadow `i := i` must be done BEFORE creating the closure, in the loop's scope, not inside the closure body.Bug 7 🟡 — Closure Holding Resource¶
package main
import (
"fmt"
"os"
"time"
)
func startWatcher(path string) {
f, err := os.Open(path)
if err != nil {
return
}
go func() {
for {
time.Sleep(10 * time.Second)
// f is captured but never used or closed
_ = f
}
}()
}
func main() {
for i := 0; i < 1000; i++ {
startWatcher("/etc/hosts")
}
select {} // block
}
What's the bug?
Solution
**Bug**: 1000 goroutines never exit, each capturing a `*os.File`. 1000 file descriptors stay open forever — easy to hit `EMFILE` on Linux. The captured `f` keeps the file open because Go's GC won't finalize it (the closure references it). **Fix** — make goroutines respect cancellation, and close on exit: **Key lesson**: Long-lived goroutines pin captured resources. Always design for cancellation and explicit cleanup.Bug 8 🟡 — Method Value Captures Stale Receiver¶
package main
import "fmt"
type S struct{ v int }
func (s S) Get() int { return s.v }
func main() {
s := S{v: 1}
get := s.Get
s.v = 99
fmt.Println(get())
}
Solution
**Bug**: Method value with VALUE receiver captures a COPY of `s` at binding time (when `v == 1`). Subsequent mutations don't affect the captured copy. Output: `1`. **Fix** (option A — pointer receiver): **Fix** (option B — call directly each time): **Key lesson**: Method values bind to value receivers by snapshot. Use pointer receivers or direct calls for live state.Bug 9 🔴 — Inadvertent Closure-Capture Race¶
package main
import (
"fmt"
"sync"
)
func main() {
cache := map[string]int{}
var mu sync.Mutex
update := func(key string, value int) {
cache[key] = value
}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
update(fmt.Sprintf("key%d", i), i)
}(i)
}
wg.Wait()
mu.Lock()
fmt.Println(len(cache))
mu.Unlock()
}
The author added a mutex but never uses it inside update. What goes wrong?
Solution
**Bug**: `mu` is captured but `update` doesn't lock it. 100 goroutines write to `cache` concurrently — race condition. Map writes from concurrent goroutines may panic or corrupt. `go run -race main.go` flags it. **Fix** — use the mutex inside `update`: **Key lesson**: Capturing a mutex doesn't synchronize anything. You must explicitly Lock/Unlock at every access site.Bug 10 🔴 — Closure Captures Slice Header¶
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
f := func() {
s = append(s, 99)
}
f()
fmt.Println(s) // expected [1 2 3 99]
f()
fmt.Println(s)
}
Solution
**Discussion**: The closure captures `s` by reference. `s = append(s, 99)` reassigns the captured `s` variable. The outer `s` sees the update because they share the variable. Output: This works correctly — but watch out for confusion about whether you're modifying the slice header (reassigning `s`) vs the underlying array. **Caveat** — if multiple closures or goroutines do this concurrently, races on the slice header are possible: Use a mutex. **Key lesson**: Closures capture slice variables (the header), not the underlying array. Reassignments are visible across the closure boundary.Bug 11 🔴 — Closure in defer With Late Variable¶
package main
import "fmt"
func process() error {
var err error
defer fmt.Println("err is:", err) // BUG
err = fmt.Errorf("something failed")
return err
}
func main() {
process()
}
Solution
**Bug**: `defer fmt.Println("err is:", err)` evaluates ARGS eagerly, at defer time, when `err == nil`. The deferred call prints "err is:Bug 12 🔴 — Goroutine Captures for range Variable¶
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"a", "b", "c"}
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item)
}()
}
wg.Wait()
}
(go.mod: go 1.21.) What's printed?
Solution
**Bug** (pre-1.22): All goroutines share the same `item`. The loop completes quickly; goroutines see `item == "c"` (the final value). Output (pre-1.22): **Fix** (option A — pass as arg): **Fix** (option B — shadow): **Fix** (option C — upgrade to Go 1.22+): Bump `go.mod` to `go 1.22`. Each `item` is per-iteration. Original code prints `a b c` (in some order). **Key lesson**: The Go 1.22 loop-variable change covers `for ... range` as well as C-style `for`.Bonus Bug 🔴 — Closure Captures Incorrectly Cleared¶
package main
import "fmt"
type Cleaner struct {
cleanup func()
}
func newCleaner(big *[1024]int) *Cleaner {
c := &Cleaner{}
c.cleanup = func() {
big = nil // BUG?
fmt.Println("cleaned")
}
return c
}
func main() {
big := &[1024]int{}
c := newCleaner(big)
c.cleanup()
// After cleanup, is `big` still alive?
}