skip to content

A Go backup archiver that calls `defer f.Close()` inside its walk loop dies with "too many open files". Why, and how do you fix it?

level: seniorimportance: should knowfreq 55%

answer

  1. the loop is not a scope
  2. nothing is released until the walk ends
  3. descriptors accumulate until the limit
  4. give each file its own function call
  5. the failing file is the innocent one

basics

~20 s

defer is scoped to the enclosing function, not the loop body, so every file opened during the walk stays open until that function returns. Give each file its own function call, so its deferred Close runs per file.

solid answer

~50 s

Each iteration registers a deferred `Close` against the *walk function*, and none of them run until that function returns — so on a directory tree of ten thousand files the archiver holds ten thousand descriptors at once and hits the process limit partway through, reporting `open /var/data/2026-09-01.log: too many open files`. The `defer` did exactly what it says; the mistake is expecting loop-body scope. The fix is to move the per-file work into its own function — `copyOne(path string, out io.Writer) error` called once per iteration — so the deferred `Close` fires at the end of each call, keeping one descriptor open at a time. Closing explicitly at the end of each iteration also works, but has to be repeated on every error path. Raising the descriptor limit is not a fix: it moves the failure to a bigger tree.

code

go · 13 lines
go
func archiveAll(paths []string, out io.Writer) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // runs only when archiveAll returns
		if _, err := io.Copy(out, f); err != nil {
			return err
		}
	}
	return nil
}

go deeper

for a junior

Recall that defer waits for the whole function, so a defer written inside a loop does not clean up per iteration. Recognise "too many open files" as a sign that handles are accumulating.

for a middle

Explain why the descriptors pile up and show the repair: extract the per-file work into its own function so the deferred Close is scoped to one file. Know why an explicit Close at the end of the loop body is fragile on error paths.

for a senior

Diagnose it from the symptom, distinguish mitigation from remediation, and name the quieter variants — a deferred Unlock or a deferred response-body Close in a loop. Say how you would test it so the regression fails in CI, not in production.

for a principal

Own the pattern-level response: a review rule about acquisition inside loops, resource budgets for tools that walk unbounded input, and whether the codebase standardises on per-item helper functions so this class of bug cannot be written in the first place.

## What actually happened The archiver walks a directory tree, opens each file, copies it into the archive, and defers the close: ```go func archiveAll(paths []string, out io.Writer) error { for _, p := range paths { f, err := os.Open(p) if err != nil { return err } defer f.Close() // runs only when archiveAll returns if _, err := io.Copy(out, f); err != nil { return err } } return nil } ``` Every iteration registers a deferred call against `archiveAll`, and deferred calls run when **that function** returns — not at the end of the loop body, not at the closing brace. So the descriptors accumulate for the entire walk. On a small tree nobody notices; on a real backup target the process reaches its open-file limit mid-walk and `os.Open` starts failing: ```text open /var/data/2026-09-01.log: too many open files ``` The error names a perfectly ordinary file, which is what makes the postmortem interesting: the file that fails is innocent, and the cause is every file opened before it. A useful confirmation while the process is still up is to compare the descriptor count in `/proc/<pid>/fd` against the limit, or simply to note that the failure index moves with the limit rather than with the data. A second, quieter cost: the function accumulates one pending deferred call per iteration, so memory grows with the walk too. On a large enough tree that alone is worth avoiding. ## The fix: give each file its own function call `defer`'s unit is the function, so the cleanest repair is to make a function whose lifetime is one file: ```go func archiveAll(paths []string, out io.Writer) error { for _, p := range paths { if err := copyOne(p, out); err != nil { return err } } return nil } func copyOne(p string, out io.Writer) error { f, err := os.Open(p) if err != nil { return err } defer f.Close() // runs at the end of every copyOne call _, err = io.Copy(out, f) return err } ``` Now exactly one descriptor is open at a time, cleanup still covers every error path inside `copyOne`, and the loop reads as one line of intent. The same shape falls out naturally when the walk uses `filepath.WalkDir`, because the callback *is* a per-entry function — the body of that callback is the right place for the open and its deferred close. ## The alternatives, and why they are worse **Close explicitly at the end of the iteration.** Correct, and sometimes the right call in a tight loop, but every `continue`, every early `return` and every error branch must remember to close first. That is the maintenance burden `defer` was designed to remove, and the bug it reintroduces is the one that is hardest to see in review — the path nobody exercised. **Wrap the loop body in an inline function literal invoked immediately.** It works, and it is a common quick fix, but it buries a function boundary inside a loop and makes the error path awkward: the literal has to return an error the loop then checks. A named helper says the same thing more legibly. **Raise `ulimit -n`.** Not a fix. It buys headroom proportional to the limit while the requirement is proportional to the tree, so the next larger backup fails the same way — and now the process holds far more kernel resources while doing so. Worth doing as an operational stopgap only if you say out loud that it is one. ## What a review should catch The rule of thumb worth writing down: **a `defer` inside a loop is a smell unless the loop is the last thing the function does and the count is bounded and small.** Whenever the loop body acquires something — a file, a network connection, a lock, a database row set — the acquisition belongs in a function of its own. The same trap has non-file forms, and they fail less loudly. A deferred `Unlock` inside a loop holds the mutex for the whole function rather than one iteration, serialising work that was meant to be interleaved. A deferred `Close` on each HTTP response body inside a loop pins connections until the function returns. ## For the postmortem Three things belong in the write-up. The **mechanism**, stated precisely: `defer` is function-scoped, so the loop registered N pending closes and released none until the walk finished. The **why it survived testing**: the fixture tree had a few dozen files and the limit is in the thousands, so the bug was invisible until real data. And the **guard**: the fix is structural (per-file function), plus a test that walks a tree larger than a deliberately lowered descriptor limit, so a regression fails in CI instead of at 3am.

  • Why is raising the process open-file limit not an acceptable fix here?
    Because the demand grows with the size of the tree while the limit is a constant. Doubling it buys one larger backup and then the same failure returns, with the process now pinning twice as many kernel descriptors while it runs. It is a legitimate stopgap to get one run through, but the postmortem has to record it as mitigation, not remediation, or the structural fix never lands.
  • What does the same mistake look like with a mutex instead of a file?
    A `defer mu.Unlock()` inside a loop body holds the lock from the first iteration until the whole function returns, instead of for one iteration. Nothing errors and nothing leaks — throughput simply collapses, because work that was meant to interleave is now fully serialised. It is harder to spot than the descriptor case precisely because the symptom is latency rather than a failure.
  • How would you write a regression test that catches this class of bug?
    Exercise the walk over a tree with more files than the descriptor budget the test allows, so accumulation fails deterministically rather than depending on the machine. Lowering the limit for the test process, or asserting a bound on concurrently open handles through the seam that opens them, both work. The point is that the fixture must be larger than the resource ceiling; a twenty-file fixture will pass no matter how broken the code is.

One deferred close per file inside a long walk is like opening every drawer in the cabinet and agreeing to shut them all only after you have finished the last one.

saying these in an interview costs you the question

  • Thinks the deferred Close runs at the end of each iteration
  • Proposes raising ulimit -n as the fix
  • Blames the specific file named in the error message
  • Says the file will be closed when it is garbage collected
  • Adds an explicit Close only on the happy path