Skip to content

Go defer, Errors, and Closures — LIFO, Error Wrapping, and the Loop Variable Trap

Contents10 sections

Go dropped exceptions and filled the gap with defer and errors as values. The syntax for all three takes thirty seconds to learn, yet the traps keep showing up years later. I reorganized them around one question: exactly when, and what, does each one hold on to?

defer: execution is postponed, arguments aren’t

If you only remember defer as “runs when the function ends,” you have half of it. More precisely, it schedules this call to run at the end of the function, right now, and at that moment every argument is evaluated and frozen.

① At registration: arguments are evaluated nowi := 0defer fmt.Println(“A:”, i)↳ arg 0 is frozendefer func() { … i … }()↳ captures ii = 1← i changes here② When the function ends: last in, first outruns first · registered lastfunc() { … i … }B: 1reads i at run timeruns last · registered firstfmt.Println(“A:”, 0)A: 0set at registrationOutput: B: 1 → A: 0
Same i, different results: the difference is argument versus closure
Arguments versus a closure
i := 0
defer fmt.Println("A:", i) // argument evaluated: 0 is frozen
defer func() { fmt.Println("B:", i) }() // closure: holds on to the variable i
i = 1
// Output: B: 1 → A: 0 (LIFO)

There’s one more thing people tend to miss: a method receiver is an argument too. In defer mu.Unlock(), what mu refers to is also fixed at registration. With a value receiver, you get a copy from that moment, which ties straight back to the “a copied lock protects nothing” point from part one.

So if you need the value at execution time, wrap it in a closure; if you want to freeze the value at registration, pass it as an argument. The latter has its uses, like defer cleanup(id) in a loop when you want to pin the id at that moment.

The real cost of defer: in a loop, you lose twice

“defer is slow” is only half true now. Since Go 1.14, open-coded defers let the compiler, when conditions allow, simply expand the deferred call at the end of the function. No runtime data structures at all, so the cost is close to zero.

The problem is when those conditions break. The classic case is a defer inside a loop, and there the cost comes in two layers.

✓When conditions hold: open-coded

  • The compiler expands the call inline at the end of the function
  • No runtime data structures, so it’s practically free
  • Conditions: at most 8 defers in the function, no defer in a loop, and returns × defers no more than 15

✕Inside a loop: piled on the heap

  • Each iteration creates a defer record on the heap and links it into a list
  • ① You pay for the allocations, and
  • ② the calls pile up until the function ends. File handles stay open until then

That compiler behavior is where “don’t defer in a loop” comes from. The well-known fix is to wrap the loop body in an anonymous function. Each iteration then cleans up as soon as that function returns, and the defer is out of the loop again, eligible for open coding.

defer in a loop
// ✕ none of the files close; they pile up until the loop ends
for _, name := range files {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // runs only when the function ends → handles run out
use(f)
}
// ✓ move the body into a function: each one closes right away
for _, name := range files {
if err := func() error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // runs when this anonymous function ends
return use(f)
}(); err != nil {
return err
}
}

When you can’t count on defer

I’ve been burned by trusting defer as cleanup that always runs. There are definitely cases where it doesn’t.

Situation Does defer run?
Normal return Yes
panic Yes, in reverse while the stack unwinds
runtime.Goexit Yes, all of this goroutine’s defers run before it exits
os.Exit No. The process ends immediately
log.Fatal and friends No. They call os.Exit(1) internally
fatal error No. Concurrent map writes and the like

The last three rows matter. log.Fatal looks like logging, but it actually ends the process on the spot. Sprinkle it into error handling for convenience and every bit of cleanup you set up with defer is gone. So it’s safest to use it nowhere but the outermost level of main. The concurrent map write fatal error from part two is the same kind: neither defer nor recover helps.

What defer f.Close() quietly swallows

Here defer does run, but it throws the result away. Close() returns an error, and defer f.Close() just discards it. For read-only files that’s usually fine, but for files you write, it’s a completely different story. Flushing buffers to disk may happen at Close, and if that fails, data is lost. The function returned nil, and the file is corrupt.

Folding the Close error into the return value
// named return + defer folds the Close error into the result
func save(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // surface the Close error only if the body succeeded
}
}()
_, err = f.Write(data)
return err
}

The named return is the key. The return value needs a name for the defer to modify it, and the same pattern shows up again in the next section, in code that turns a panic into an error.

panic and recover: unwinding and three conditions

When a panic happens, Go first unwinds the stack, running each frame’s deferred calls in reverse. If recover is called inside one of those deferred calls along the way, unwinding stops there and that function is treated as if it returned normally.

parse()panic · unwinding starts herehandle()recover() in a defer · unwinding stopsmain()sees handle as if it simply returnedunwindWhile unwinding, each frame’s defers run in reverse
recover stops the unwinding only when a deferred function calls it directly, in the same goroutine

For recover to work, all three conditions must hold.

  1. It’s inside a deferred function,
  2. that function calls recover() directly, and
  3. it’s in the same goroutine that panicked.

The second condition is the sneaky one. The deferred function has to call it directly, and skipping even one level makes it useless. This is where code breaks silently when someone pulls common error handling into a helper.

The third is scarier. If one goroutine dies from a panic, the whole program dies. No matter where you put recover up top, a panic in another goroutine isn’t caught. So in a server, every place you launch a goroutine needs its own guard.

Panic to error, and a guard at every goroutine boundary
// the standard pattern for turning a panic into an error: named return + recover
func safeParse(b []byte) (result Node, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse panic: %v", r) // overwrite the named return
}
}()
return parse(b), nil // a panic inside parse becomes an error here
}
// a guard at every goroutine boundary: without it, one crash takes the process down
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v\n%s", r, debug.Stack())
}
}()
doWork()
}()

So when is panic the right tool? The test is whether the caller can handle the failure and carry on.

✓Where panic fits

  • Unrecoverable initialization failures (the MustCompile family)
  • Programmer mistakes that break an invariant
  • Escaping all the way out of a deeply recursive parser in one go

✕Where panic is wrong

  • Failed input validation, network or file errors, no results found
  • Every failure you can anticipate is an error
  • Use panic as control flow and the caller has no way to respond

To expand on the third case: the standard library also uses panic inside deeply recursive parsers to escape in one shot, and then always converts it to an error at the package boundary. The point is that the panic never leaks out of the package. Using it internally is fine; a public API must not panic.

🧪 Try it

Two ways recover fails silently

To check whether the “call it directly” condition is real, I ran a version that calls it through a helper next to one that calls it directly.

A direct call versus one level removed
func cleanup() {
if r := recover(); r != nil {
fmt.Println("caught:", r)
}
}
func direct() { defer cleanup(); panic("boom") } // direct call
func indirect() { defer func() { cleanup() }(); panic("boom") } // one level removed
Output
direct() → caught: boom
indirect() → panic: boom [program exits]

Same cleanup function: defer cleanup() catches it, defer func() { cleanup() }() doesn’t. One extra wrapper and the guard is gone, with no compile error and no warning.

A bonus finding: since Go 1.21, panic(nil) no longer passes through as is; it becomes a *runtime.PanicNilError panic. Previously recover() returned nil, so code could conclude “no panic” in the middle of one. The language closed that hole.

Errors are values: what fills the gap left by exceptions

In Go, error is a one-method interface, and an extreme example of the small-interface culture from part one.

The definition of error
type error interface {
Error() string
}
// That's all. No special treatment in the language; it's just a value

Dropping exceptions didn’t drop syntax; it dropped invisible control flow. In a language with exceptions, any function call can jump out at that line, and you can’t see it in the code. In Go, every point that can fail is visible as if err != nil.

Exceptions (try/catch) Values (Go)
Error path Invisible in the code Visible at every point
If ignored Propagates up automatically Must be discarded explicitly
Composition Tied to language syntax Just values, handled freely
Cost Harder to debug Verbose

The last row is the honest trade-off. The complaint that if err != nil is tedious is true, and the Go team knows it. They chose “tedious, but impossible to miss.”

One small rule teams often get out of sync on: error strings start lowercase and don’t end with a period. Higher layers keep prefixing context, as in fmt.Errorf("saving user: %w", err), so a capital letter or a period in the middle breaks the sentence awkwardly.

Error chains: the contract %w creates

When you pass an error up, you want to add context without losing the cause. That’s what %w is for. %v melts the cause into a string, while %w wraps it with the original value intact.

%w · the chain holds“process order: %w”context from the handler“query user 42: %w”context from the repositorysql.ErrNoRowsthe real cause · sentinelerrors.Is → true%v · the chain breaks“process order: %v”the cause survives as text only✕ chain brokensql.ErrNoRowsthe program can’t find iterrors.Is → false
The message may read the same to a person; what differs is whether a program can work with it
Is, As, and Join
var ErrNotFound = errors.New("not found") // sentinel
func find(id string) error {
return fmt.Errorf("find %s: %w", id, ErrNotFound) // keeps the chain
}
// ① is it the same error? found no matter how many layers deep
if errors.Is(err, ErrNotFound) { /* respond with 404 */ }
// ② is it this type? + extract the concrete value
var pe *fs.PathError
if errors.As(err, &pe) { log.Println(pe.Path) }
// ③ several errors at once (Go 1.20+)
joined := errors.Join(errValidate, errNotify) // both match with Is
both := fmt.Errorf("two failures: %w, %w", errValidate, errNotify) // multiple %w works too

The mechanism is simple. errors.Is and errors.As keep calling Unwrap() and compare as they walk down the chain. So the moment you add Unwrap() to your own error type, it joins the chain, and implementing an Is(target error) bool method lets you customize the comparison too.

But %w isn’t free

The moment you wrap with %w, the wrapped error effectively becomes part of your public API. Once callers start checking errors.Is(err, sql.ErrNoRows), swapping out your DB driver later quietly breaks someone else’s code.

Strategy When to use it
Wrap with %w Within the same package or module, when the caller needs the cause to respond
Cut with %v At package or service boundaries, to hide internals. Which driver you use shouldn’t leak out
Translate to a domain error The best choice at boundaries. Turn sql.ErrNoRows into your own ErrUserNotFound

✕Internals leak into the contract

func (r *Repo) GetUser(id string) (*User, error) {
// callers start depending on sql.ErrNoRows
return nil, fmt.Errorf("get user %s: %w", id, sql.ErrNoRows)
}

✓Translate at the boundary

var ErrUserNotFound = errors.New("user not found")
func (r *Repo) GetUser(id string) (*User, error) {
var u User
err := r.db.QueryRow(q, id).Scan(&u.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user %s: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %s: %v", id, err) // cut driver errors
}
return &u, nil
}

I used to reach for %w by default. Now I stop each time I wrap and ask whether I can keep guaranteeing this wrapped error going forward. If I can, %w; if not, I cut it at the boundary or translate it into my own domain error.

Closures: holding on to the outer variable

A closure is a function bundled with the environment it holds on to, and it holds on to the variables themselves instead of copying their values. So if the variable changes outside, the value the closure sees changes too.

Why a captured variable survives after the function returns is answered by escape analysis. The compiler asks whether the value’s lifetime extends beyond the function’s frame. When a closure is returned, as in counter below, n can’t disappear with the frame, so it moves to the heap, and the closure value carries the function code pointer and the address of the captured n.

Uses of closures
// a function with state: n moves to the heap and survives
func counter() func() int {
n := 0
return func() int { n++; return n } // captures n by reference
}
c := counter()
c(); c() // 1, 2: state carries across calls
// middleware: a closure wrapping a handler (the decorator pattern)
func withLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Println(r.URL.Path, time.Since(start)) // captures next and start
})
}
// the functional options pattern: closures + variadic arguments
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
srv := NewServer(WithTimeout(3*time.Second), WithRetries(2))

The cost comes from the same place. When a closure escapes the function (returned or handed to a goroutine), the closure and its captured variables go to the heap, and overusing such closures on a hot path drives up allocs/op. Conversely, a closure used and discarded within the function stays on the stack, and if a captured variable is small and never changes afterward, the compiler may copy it in by value.

The quieter problem is capturing a large object. Even if the closure reads a single field, as long as it holds the variable pointing to that object, the whole object can’t be garbage-collected while the closure lives. It’s the same structure as the reslicing leak from part two.

Loop variables: a bug the language fixed itself

This is where “captures the variable” caused its most famous accidents. Up to Go 1.21, loop variables weren’t created fresh each iteration; a single one was reused. Launch goroutines inside the loop and they all saw the same variable. What got printed was usually just the last value.

Go 1.21 and earlier · one variableg1g2g3v = “c”Output: c c call three see the same variableGo 1.22+ · a new variable each iterationg1g2g3“a”“b”“c”Output: a b ceach sees its own (in any order)
One box or three: that's everything Go 1.22 changed

The most important part is that this change doesn’t apply automatically. Upgrading the toolchain to 1.26 doesn’t help; the go directive in go.mod must be 1.22 or later for the new behavior. It’s a backward-compatibility safeguard so existing code keeps its meaning, which also means old modules still get the old behavior even when built with the latest Go.

go.mod, and an idiom that's safe on any version
// go.mod
module example.com/app
go 1.22 // below 1.22, even the latest Go builds with the old behavior
// an idiom that's safe on any version, and still common because the intent is clear
for _, v := range items {
go func(v Item) { process(v) }(v) // pass it as an argument to copy the value
}

Real incidents show how dangerous this bug is. A published Let’s Encrypt outage is the best-known one, and the author knew about the issue. They had already copied the loop variable k, but another function was holding a pointer to a different loop variable, v, and that’s where it blew up. Once captures are spread across several functions, catching them by eye is practically impossible.

Tests that quietly pass

This bug can also make a failing test pass. The test below should fail, since 1 is odd, but on 1.21 and earlier it passes.

A test that verifies nothing
func TestAllEven(t *testing.T) {
for _, v := range []int{1, 2, 4, 6} {
t.Run("sub", func(t *testing.T) {
t.Parallel() // waits until the loop finishes
if v%2 != 0 {
t.Fatal("odd v", v)
}
})
}
}
// ≤1.21: when subtests run, v is already 6 → every check is "is 6 even?" → passes
// 1.22+: v is separate per iteration → correctly fails on 1

t.Parallel() holds subtests until the loop finishes, so by the time they run, v already has its last value. The tests are green and verify nothing. The Go team reportedly had to fix tests like this in bulk while preparing the change.

Wrap-up

  • defer postpones only the execution; arguments and receivers are fixed at registration. In front of os.Exit, log.Fatal, and fatal errors, it doesn’t run at all.
  • A defer in a loop holds resources until the function ends and breaks the open-coded conditions.
  • recover has to be called directly by the deferred function, and it can’t catch panics in other goroutines, so every place that launches a goroutine gets its own guard.
  • Wrapping with %w makes the inner error part of your API.
  • An escaping closure allocates on the heap, and if it holds a large object, the whole object stays alive. Go 1.22 fixed the loop variable problem, but only once the go line in go.mod is bumped.

Writing this part, I realized one question runs through all three: exactly when, and what, does this hold on to? defer holds the arguments at registration, a closure holds the variable itself, and %w holds the wrapped error. Every pitfall in this post came from misjudging what was being held.

References

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›