skip to content

How defer Is Implemented

How the runtime records deferred calls and runs them in reverse on the way out, plus the two details that trip people up: arguments are evaluated when defer executes, not when the function returns, and a deferred closure can still change a named return value. The loop-defer resource leak is the standard follow-up.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why does `defer f.Close()` inside a Go for loop keep every file open until the function returns?

level: juniorimportance: must knowfreq 62%

answer

  1. function-scoped, not block-scoped
  2. each iteration pushes another one
  3. the frame's pending list keeps growing
  4. descriptors live until the outer return
  5. give each iteration its own call

basics

~20 s

Go's defer is scoped to the function call, not to the block or the loop iteration. Every trip through the loop registers one more deferred Close against the same frame, and none of them run until the enclosing function returns.

solid answer

~50 s

A `defer` statement registers a call against the *current function call*, not the surrounding block. Each time the loop body reaches `defer f.Close()`, one more deferred call is pushed onto that frame's defer list; they all run, last registered first, only when the enclosing function finally returns. Walk a directory with a few thousand files and you hold a few thousand descriptors at once, until the process hits its per-process limit and `os.Open` starts failing with `too many open files`. The fix is to give each iteration its own function call: move open/use/close into a small named helper or an immediately invoked closure, so the `defer` inside it fires at the end of every iteration. If you would rather not add a function, close explicitly and check the error. Watching the process's open-descriptor count climb monotonically while the loop runs confirms the diagnosis.

code

go · 13 lines
go
func rotate(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // pushed once per iteration, run only at rotate's return
		if _, err := io.Copy(io.Discard, f); err != nil {
			return err
		}
	}
	return nil
}

go deeper

for a junior

Be ready to state the scope rule out loud: defer belongs to the function call, not the block or the iteration. Then show the fix by extracting the loop body into its own function.

for a middle

Explain the mechanics: each execution of the defer statement registers another pending call for the same frame, so N iterations mean N pending calls and N held resources until the function returns.

for a senior

Show how you would recognise this in production from the symptom alone: a job that fails partway through with too many open files, and a descriptor count that climbs for the whole run and collapses at the end.

for a principal

Frame it as a review and convention question. Decide whether the codebase's rule is never defer inside a loop or always extract a per-item function, and make sure the linting or review checklist catches it before it reaches a long-running job.

## What `defer` is actually attached to When the Go runtime executes a `defer` statement, it records a pending call **for the frame of the function that is currently executing**. There is no block-level or loop-level bookkeeping anywhere in the language or the runtime: braces do not create a defer scope, and neither does a loop iteration. The only event that drains a frame's deferred calls is that frame going away. So `defer` inside a `for` body means "do this once per iteration, later, all at once, when the function returns" — which is almost never what someone writing resource cleanup intends. ## The shape of the bug A file-rotation CLI walks a tree (say with `filepath.WalkDir`) and, for each path, opens the file, streams it somewhere, and defers the close: ```go for _, p := range paths { f, err := os.Open(p) if err != nil { return err } defer f.Close() // ... use f ... } ``` After iteration 1 there is one pending `Close`. After iteration 5,000 there are 5,000 pending `Close` calls and 5,000 open descriptors, because the enclosing function has not returned yet. Two things grow together: the operating-system descriptors, and the frame's defer list itself (each pending call needs its own record, and a defer inside a loop is exactly the case the compiler cannot optimise away into inline code). ## How it shows up The process does not crash at the defer; it crashes at the *next* `os.Open`, which returns an error whose text ends in `too many open files`. That is the operating system refusing a new descriptor because the process is at its limit. The tell that separates this from a genuine leak elsewhere is the shape of the curve: the open-descriptor count for the process rises monotonically for the whole run and then drops to nothing the instant the function returns — the descriptors were never leaked, merely held far too long. A reader onboarding to the codebase often misreads this as "we forgot to close somewhere". The `Close` is right there in the source, which is what makes the bug persuasive. ## The idiomatic fix: one function call per iteration Because `defer` fires when a *function call* returns, give each iteration its own call: ```go for _, p := range paths { if err := handle(p); err != nil { return err } } func handle(p string) error { f, err := os.Open(p) if err != nil { return err } defer f.Close() // ... use f ... return nil } ``` Now the descriptor is released at the end of every iteration, and the defer list never holds more than one entry. An immediately invoked function literal inside the loop does the same job when a named helper would be overkill, though a named helper usually reads better and is easier to test. The other legitimate option is to drop `defer` in the loop and close explicitly at each exit path from the body. That is more error-prone precisely because a loop body typically has several `continue`/`break`/early-`return` paths, and each one has to remember the close — which is the reason `defer` exists in the first place. ## Why the language does not make `defer` block-scoped Block-scoped cleanup would need the compiler and runtime to track and drain pending calls at every brace, and it would break the single most common use of the feature: registering cleanup at the top of a function so that *every* return path, including ones added months later, is covered. Function scope is what makes `defer mu.Unlock()` on line two a durable guarantee. The loop case is the price of that choice, and the price is paid by extracting a function. ## What to say in an interview Name the scope rule first ("defer is per function call, not per block"), then the consequence (N pending calls, N held resources), then the fix (extract the body into a function so each iteration is its own call), then the observable (`too many open files`, descriptor count climbing for the life of the loop). That ordering shows you understand the mechanism rather than having memorised a rule of thumb.

  • Does hoisting the loop body into an immediately invoked function literal work as well as a named helper?
    Yes. `defer` fires when a function call returns, and a function literal invoked on the spot is a real call, so its deferred close runs at the end of that iteration. A named helper is usually preferable for readability and testability, but the mechanism is identical. What does not work is wrapping the body in a bare block: braces alone create no call and therefore no defer boundary.
  • How would you confirm at runtime that the descriptors are held rather than leaked?
    Watch the process's open-descriptor count while the loop runs: held descriptors climb steadily and then fall to the baseline the moment the enclosing function returns, whereas a true leak stays high afterwards. On Unix you can count the entries under the process's fd directory or use a descriptor-listing tool, and compare against the per-process limit that produced the `too many open files` error.
  • Is there any performance cost to the accumulated defers beyond holding the files open?
    Yes, a small one. A `defer` inside a loop is the case the compiler cannot turn into inline code at the return points, so each iteration allocates and links a pending-call record for the frame. With thousands of iterations that is thousands of records living until the function returns. The descriptors are the operational problem; the records are a measurable but secondary memory cost.

It is a coat check that only hands everything back when you leave the building, not when you leave each room.

saying these in an interview costs you the question

  • Thinks defer runs at the end of the enclosing block
  • Thinks defer runs at the end of each loop iteration
  • Blames the garbage collector for not closing the file
  • Fixes it only by raising the process descriptor limit
  • Claims defer never runs unless the function returns normally
open as a page

In Go, what are open-coded defers and when does the compiler fall back to runtime defer records?

level: middleimportance: should knowfreq 40%

basics

~20 s

An open-coded defer is one the Go compiler inlines: it keeps the call in stack slots, sets a bit in a bitmask, and calls it at each return. Defers in a loop, or more than eight per function, fall back to runtime records.

open as a page

In Go, what does `return 5` compile to in a function with deferred calls and a named result?

level: middleimportance: should knowfreq 55%

basics

~20 s

It compiles to three steps: assign 5 into the named result's slot, run the frame's deferred calls, then return to the caller. Because the defers run in between, a deferred closure writing to that named result changes what the caller receives.

open as a page

Your team bans `defer` in hot Go functions. How would you check whether that rule still pays?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Measure rather than argue. Benchmark the real function with and without the defer on an optimized build, then read its assembly for the runtime defer symbols. On current Go the rule usually only survives for defers inside loops.

open as a page