Introduction
In Go, defer statements are not executed at the end of the loop iteration -- they are executed when the surrounding function returns. In a tight loop that opens files, creates connections, or allocates resources, this causes all deferred cleanups to pile up until the function exits, potentially exhausting file descriptors or memory.
This is a subtle Go gotcha that developers from other languages (where cleanup runs at scope exit) commonly encounter.
Symptoms
- Application runs out of file descriptors after processing many files in a loop
- Memory usage grows linearly with loop iterations despite defer cleanup
- Error: "too many open files" after processing a few hundred items
Common Causes
- defer inside a loop doesn't execute until the function returns, not at loop iteration end
- Each iteration accumulates deferred calls that are all held until function exit
- Loop processes thousands of items, each deferring a file close or resource release
Step-by-Step Fix
- 1.Wrap loop body in an anonymous function: Defer runs when the anonymous function returns.
- 2.```go
- 3.// BAD: defer accumulates until processAll returns
- 4.func processAll(files []string) error {
- 5.for _, f := range files {
- 6.fh, err := os.Open(f)
- 7.if err != nil {
- 8.return err
- 9.}
- 10.defer fh.Close() // NOT executed until processAll returns!
- 11.// process fh...
- 12.}
- 13.return nil
- 14.}
// GOOD: defer runs each iteration func processAll(files []string) error { for _, f := range files { if err := func() error { fh, err := os.Open(f) if err != nil { return err } defer fh.Close() // Runs at end of anonymous function return processFile(fh) }(); err != nil { return err } } return nil } ```
- 1.Use explicit Close instead of defer in loops: Call Close directly at the end of each iteration.
- 2.```go
- 3.func processAll(files []string) error {
- 4.for _, f := range files {
- 5.fh, err := os.Open(f)
- 6.if err != nil {
- 7.return fmt.Errorf("open %s: %w", f, err)
- 8.}
err = processFile(fh) fh.Close() // Explicit close, runs immediately if err != nil { return fmt.Errorf("process %s: %w", f, err) } } return nil } ```
- 1.Use runtime.SetFinalizer as safety net: Not a replacement for proper cleanup, but catches leaks.
- 2.```go
- 3.import "runtime"
type Resource struct { file *os.File }
func NewResource(path string) (*Resource, error) { fh, err := os.Open(path) if err != nil { return nil, err } r := &Resource{file: fh} runtime.SetFinalizer(r, func(r *Resource) { if r.file != nil { log.Printf("WARNING: Resource not properly closed: %s", path) r.file.Close() } }) return r, nil }
func (r *Resource) Close() error { runtime.SetFinalizer(r, nil) return r.file.Close() } ```
Prevention
- Never use defer in a tight loop -- use explicit cleanup or wrap in a function
- Use golangci-lint with the fatcontext and bodyclose linters
- Set ulimit -n lower in development to catch file descriptor leaks early
- Review all loops that open resources as part of code review checklist