The defer in a Loop Gotcha in Go
You write a loop to process a batch of log files. Inside the loop, you open a file and add a defer to close it. The logic looks clean. You run the script, and it crashes with a "too many open files" error, or worse, it runs silently but the cleanup logic runs against the wrong file handle every single time. The bug isn't in your file handling. It's in how Go captures variables inside a loop before version 1.22.
This pattern trips up developers coming from languages where loop variables are scoped per iteration, or where closures capture values by default. Go's closure semantics are consistent, but the loop variable scoping changed in a way that creates a silent trap in older versions. Understanding the mechanism prevents a class of bugs that only manifest when the function containing the loop finally returns.
Closures capture variables, not values
When you write an anonymous function inside a loop, that function doesn't grab the value of the variable at that moment. It grabs a reference to the variable itself. The anonymous function forms a closure over the variable's memory slot.
Think of the loop variable as a whiteboard in a control room. Every iteration writes a new number on the whiteboard. If you hand a technician a note that says "look at the whiteboard," they always see whatever is written there when they finally look, not what was written when you handed them the note. In Go versions before 1.22, the loop variable is that single whiteboard shared across all iterations.
A defer statement schedules a function to run when the surrounding function returns. By the time the surrounding function returns, the loop has finished. The loop variable holds its final value, usually the value that caused the loop condition to fail. Every deferred function reads that same final value.
Minimal example: the shared whiteboard
Here's the classic trap. This code prints the same number five times, not 0 through 4.
package main
import "fmt"
func main() {
// Pre-Go 1.22 behavior: loop variable i is shared across all iterations.
for i := 0; i < 5; i++ {
// The anonymous function captures the variable i by reference.
// It does not capture the value of i at this specific iteration.
defer func() {
fmt.Println(i)
}()
}
}
# output:
5
5
5
5
5
The loop runs five times. Each iteration schedules a deferred function. The loop variable i increments from 0 to 4, then becomes 5 to exit the loop. main returns. The deferred functions execute in LIFO order, but they all read the same memory slot for i, which now holds 5.
Walkthrough: what happens at runtime
The compiler allocates one stack slot for the loop variable i. The anonymous function closes over that slot. When the loop body executes, the compiler updates the slot. The deferred functions are registered with the runtime, holding a pointer to the closure environment, which includes the pointer to the slot.
When main returns, the runtime executes the deferred calls. Each call evaluates the closure. The closure reads i from the slot. The slot contains 5. The result is five prints of 5.
This behavior is consistent with Go's general rule: closures capture variables, not values. The loop variable is just a variable that happens to be updated repeatedly. The closure doesn't know it's a loop variable. It just sees a variable that changed.
Realistic example: mutexes and cleanup
The bug becomes dangerous when the deferred action has side effects. Consider a loop that locks a mutex, does work, and defers the unlock.
package main
import (
"fmt"
"sync"
)
func processMutexes(mutexes []sync.Mutex) {
for i := range mutexes {
// Lock the mutex at index i.
mutexes[i].Lock()
// BUG: defer captures i by reference.
// When the function returns, i holds the final loop value.
// All defers will attempt to unlock the same mutex.
defer mutexes[i].Unlock()
fmt.Println("Working on", i)
}
}
This code locks mutexes[0], then mutexes[1], and so on. The locks accumulate. When the function returns, the deferred unlocks all target mutexes[last]. The runtime panics because you're unlocking a mutex that was never locked, or unlocking the same mutex multiple times. The earlier mutexes remain locked forever, causing deadlocks in any concurrent code.
The fix is to create a local variable inside the loop body. This allocates a unique slot for each iteration.
package main
import (
"fmt"
"sync"
)
func processMutexesSafe(mutexes []sync.Mutex) {
for i := range mutexes {
// Create a new local variable to capture the current index.
// This breaks the reference to the loop variable.
idx := i
mutexes[idx].Lock()
// Now the defer captures idx, which is unique per iteration.
defer mutexes[idx].Unlock()
fmt.Println("Working on", idx)
}
}
The local variable idx is a fresh variable for each iteration. The closure captures idx. When the deferred function runs, it reads the idx that was created in that specific iteration. The unlock targets the correct mutex.
Convention aside: the community accepts if err != nil boilerplate because it makes the unhappy path visible. In a loop with cleanup, you often see if err != nil { return err } or continue. The error handling pattern doesn't change the capture semantics, but it reminds you that the loop body might exit early. Early exits don't affect defer behavior: deferred functions always run when the surrounding function returns, regardless of how the loop ends.
Go 1.22 changes the rules
Go 1.22 changes the scoping of loop variables. The compiler now creates a new variable for each iteration automatically. This matches the behavior of for range clauses that return index and value, where the value is already a new variable per iteration.
If you write the buggy pattern in Go 1.22+, the compiler rejects the code with loop variable i captured by func literal. This is a hard error. You must create a local copy, or rely on the new scoping if you're using the loop variable directly in a way the compiler can verify.
The error message is precise. It tells you exactly which variable is captured. Fix the code by assigning the loop variable to a local variable, or refactor the loop body into a helper function.
Pitfalls and performance
Defer is convenient, but it has a cost. Each defer allocates memory and registers a call with the runtime. In a tight loop with thousands of iterations, deferring cleanup inside the loop can become a performance bottleneck. The deferred functions don't run until the surrounding function returns, so the runtime accumulates a large list of pending calls.
If you're processing a large batch, consider calling cleanup directly at the end of the iteration instead of deferring. This avoids the allocation and keeps the work synchronous.
package main
import (
"fmt"
"os"
)
func processFilesDirect(paths []string) {
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
fmt.Println(err)
continue
}
// Read file...
// Call cleanup directly to avoid defer overhead.
f.Close()
}
}
Direct cleanup is faster and uses less memory. Use defer when the cleanup must happen even if the iteration exits early due to an error or a return. Use direct cleanup when the flow is linear and performance matters.
Another pitfall is deferring inside a loop that spawns goroutines. If the goroutine captures the loop variable, the same capture bug applies. The goroutine might read the variable after the loop has moved on. The fix is the same: capture the variable in a local variable before spawning the goroutine.
Closures capture variables, not values. Copy the value if you need the snapshot.
Decision matrix
Use a local variable assignment inside the loop when you are on Go 1.21 or earlier and need to capture the loop variable in a closure. Use a helper function to wrap the loop body when the logic is complex and you want the compiler to enforce scope boundaries. Use immediate cleanup calls when the work is synchronous and you don't need deferred execution. Use the loop variable directly in Go 1.22+ when the compiler creates a new variable per iteration automatically.