defer in a Loop: The File Descriptors That Pile Up2026-05-11
This function is supposed to open each file in a list, process it, and close it cleanly — even if processing fails. It works perfectly on a handful of files. Then someone runs it against a directory with 50,000 entries and the program crashes with too many open files.
func processFiles(filenames []string) error {
for _, name := range filenames {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("open %s: %w", name, err)
}
defer f.Close()
if err := process(f); err != nil {
return fmt.Errorf("process %s: %w", name, err)
}
}
return nil
}
func process(f *os.File) error {
// read, parse, do work...
return nil
}
defer schedules a call to run when the surrounding function returns — not when the surrounding block or loop iteration ends. Go has no block-scoped defer. So every defer f.Close() inside the loop is queued onto processFiles's defer stack and only fires after the loop has finished iterating over all 50,000 files.
During the loop, every file you've opened so far is still open. By iteration 1,024 (or whatever ulimit -n says), os.Open starts returning EMFILE and the whole thing falls over. Even if you stay under the fd limit, you've held thousands of kernel file descriptors for no reason, blocking unmount, exhausting inotify watches, and keeping buffers pinned in memory.
This bug is especially nasty because:
defer looks correct — it's the canonical Go idiom for cleanup.go vet doesn't flag it. Static analyzers like staticcheck have a rule (SA5001-ish), but it's easy to miss.Push each iteration into its own function — typically a closure — so defer fires per file:
func processFiles(filenames []string) error {
for _, name := range filenames {
if err := processOne(name); err != nil {
return err
}
}
return nil
}
func processOne(name string) error {
f, err := os.Open(name)
if err != nil {
return fmt.Errorf("open %s: %w", name, err)
}
defer f.Close() // fires when processOne returns, not processFiles
if err := process(f); err != nil {
return fmt.Errorf("process %s: %w", name, err)
}
return nil
}
Or inline it with an anonymous function if you don't want a named helper:
for _, name := range filenames {
err := func() error {
f, err := os.Open(name)
if err != nil { return err }
defer f.Close()
return process(f)
}()
if err != nil { return err }
}
A related subtlety: even if you remember to call f.Close() explicitly at the end of the loop body, an early return on error will skip it and leak. The closure pattern handles both cases uniformly — that's why it's idiomatic.
One more wrinkle: defer's arguments are evaluated at defer time, not at call time. So defer fmt.Println(i) in a loop captures each iteration's i snapshot, which surprises people coming the other direction. Two different traps, same keyword.
defer is function-scoped, not block-scoped — if you need per-iteration cleanup, you need a per-iteration function.
