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.
i := 0defer fmt.Println("A:", i) // argument evaluated: 0 is frozendefer func() { fmt.Println("B:", i) }() // closure: holds on to the variable ii = 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.
// ✕ none of the files close; they pile up until the loop endsfor _, 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 awayfor _, 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.
// named return + defer folds the Close error into the resultfunc 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.
For recover to work, all three conditions must hold.
- It’s inside a deferred function,
- that function calls
recover()directly, and - 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.
// the standard pattern for turning a panic into an error: named return + recoverfunc 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 downgo 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
MustCompilefamily) - 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.
func cleanup() { if r := recover(); r != nil { fmt.Println("caught:", r) }}
func direct() { defer cleanup(); panic("boom") } // direct callfunc indirect() { defer func() { cleanup() }(); panic("boom") } // one level removeddirect() → caught: boomindirect() → 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.
type error interface { Error() string}// That's all. No special treatment in the language; it's just a valueDropping 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.
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 deepif errors.Is(err, ErrNotFound) { /* respond with 404 */ }
// ② is it this type? + extract the concrete valuevar pe *fs.PathErrorif errors.As(err, &pe) { log.Println(pe.Path) }
// ③ several errors at once (Go 1.20+)joined := errors.Join(errValidate, errNotify) // both match with Isboth := fmt.Errorf("two failures: %w, %w", errValidate, errNotify) // multiple %w works tooThe 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.
// a function with state: n moves to the heap and survivesfunc 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 argumentstype 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.
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.modmodule 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 clearfor _, 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.
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 1t.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
deferpostpones only the execution; arguments and receivers are fixed at registration. In front ofos.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.
recoverhas 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
%wmakes 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
goline ingo.modis 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
- Defer, Panic, and Recover (Go blog)
- Proposal: Low-cost defers through inline code (the open-coded defer design doc)
- Working with Errors in Go 1.13 (Go blog, on
%wanderrors.Is/As) - Fixing For Loops in Go 1.22 (Go blog, the loop variable change and the test examples)
- Go 1.21 release notes (the
panic(nil)change) - 2020.02.29 CAA Rechecking Bug (Let’s Encrypt incident report)