skip to content

Deferred Calls and Panics

The machinery behind Go's cleanup and failure path: how deferred calls are recorded and replayed, what a panic does as it unwinds a goroutine, and the narrow rules that make recover work at all.

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

explore

questions

12

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

What does a Go panic do to the goroutine's stack, and how does the program end if nothing recovers it?

level: juniorimportance: must knowfreq 80%

basics

~20 s

A panic stops normal execution and unwinds the goroutine's stack, running every deferred call on the way up. Unrecovered, the runtime prints the panic value plus a stack trace to standard error and exits the whole process with status 2.

open as a page

In a Go panic traceback, what do a frame's function line and its indented file:line line each tell you?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Each Go traceback frame is two lines: the qualified function name with hex words for its arguments, then an indented source file and line plus a +0x offset into the function. Frames print innermost first, so the top one panicked.

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

Which common Go operations raise a runtime panic, and what kind of value does the runtime panic with?

level: middleimportance: should knowfreq 62%

basics

~20 s

Memory- and type-safety violations panic: dereferencing a nil pointer, indexing or slicing out of range, integer division by zero, a failed single-value type assertion, writing to a nil map, and closed-channel misuse. The runtime panics with a value satisfying the runtime.Error interface.

open as a page

What does the traceback header `goroutine 84 [chan receive, 12 minutes]:` tell you about that goroutine?

level: middleimportance: should knowfreq 50%

basics

~20 s

It is goroutine number 84, the runtime parked it while it was receiving from a channel, and it has been blocked for about twelve minutes. The duration is printed only once a goroutine has been waiting for minutes.

open as a page

A Go HTTP handler panics with a nil pointer dereference after decoding a JSON body — how do you diagnose it at 3am from the panic output?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Read the topmost frame in your own code from the logged stack trace: that file and line is the dereference. The usual cause is a pointer or interface field that decoding left nil because the key was absent or null, then used without a check. Fix by validating after decode.

open as a page

A crashed queue worker's GOTRACEBACK=all dump shows 300 goroutines parked in `chan receive` for 40 minutes — how do you read it?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Start with the goroutine marked running under the crash message; the parked ones are context. Then group identical stacks, read their created by line to find the one go statement that spawned them, and ask which goroutine was supposed to be sending.

open as a page

What happens in Go when a deferred function panics while the goroutine is already unwinding from an earlier panic?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

Unwinding continues with the new panic, and the remaining deferred calls further up the stack still run. The runtime remembers the chain and prints every panic, the original first with each later one indented beneath it, before exiting with status 2.

open as a page

In the Go traceback frame `main.process(0xc000180000, 0x400, 0x400, 0x2)`, what are those hex values?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

They are raw machine words from that frame's argument area, printed in hex rather than decoded as Go values. Multi-word types such as slices and strings expand into several words, so the count rarely matches the parameter count.

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