Go — defer Inside a Loop Defers Cleanup Until Function Exit
Fix resource leaks where defer inside Go loops accumulates until function return, extract loop bodies into functions, and verify with descriptor counts.
Symptoms
- File descriptors or connections accumulate during batch processing and only release when the enclosing function returns
- A long-running function processing thousands of items hits the fd limit mid-run
- Resources appear to leak in production but tests that finish quickly never notice
Possible causes
- defer file.Close() inside a for loop runs at function exit, not at iteration end
- Each iteration stacks another deferred call, holding every resource open simultaneously
- The same shape with mutexes serializes nothing: the unlock never happens during the loop
- A refactor moved cleanup into defer without moving the body out of the loop
Diagnosis steps
Watch descriptors during the run
watch -n2 "ls /proc/$(pgrep app)/fd | wc -l" # while batch runsInterpretation
A count rising one per iteration and never dipping until the batch ends matches deferred-until-exactly-function-return semantics.
Find defers nested in loops
grep -Rn -B8 "defer " src/ | grep -E "for |defer " | head -30Interpretation
A defer within eight lines above a for header (or inside the loop body) is the pattern to fix.
Confirm with a bounded test
go test -run TestBatch -v ./batch/ # lower ulimit to 64 in the test envInterpretation
With a small fd ceiling, the leaking variant fails deterministically mid-batch instead of only in production.
Root cause
defer schedules cleanup at function exit, not at scope exit. Inside a loop, every iteration adds another deferred call to the function stack, so all files, connections, or locks stay open simultaneously until the enclosing function finally returns.
Fix
Extract the loop body into a function or method so defer binds to the iteration, close resources explicitly at iteration end where extraction does not fit, or restructure to a worker pool where each worker function owns its resources.
Risk note
Replacing defer with manual close calls reintroduces the early-return leak problem defer exists to solve. Prefer extraction over manual calls.
Verify the fix
Run the batch with a low fd limit and assert completion; assert descriptor count stays flat across iterations.
Symptom walkthrough
The resource graph has a staircase shape:
fd count: 3 → 4 → 5 → 6 → ... → 1024 → crash: too many open files
One descriptor per iteration, never released mid-run — because the cleanup is stacked, not executed:
func processAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // runs when processAll returns — after the whole loop
handle(f)
}
return nil
}
A thousand paths means a thousand simultaneously open files. Mutexes fail differently but identically in origin: defer mu.Unlock() inside the loop means no unlock happens between iterations, serializing nothing and often deadlocking.
Fix in practice
Give defer the right scope by extracting the body:
func processAll(paths []string) error {
for _, p := range paths {
if err := processOne(p); err != nil {
return err
}
}
return nil
}
func processOne(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // bound to this iteration
return handle(f)
}
The extraction also improves the error path: each iteration owns its cleanup, and errors do not strand accumulated deferred calls. For heavyweight setups, a worker pool gives each worker its own function boundary, achieving the same scoping with bounded concurrency.