Skip to content
gohacks.dev
Basics

Defer in Go and the Exact Order That Things Run In

Deferred calls run last in first out when a function returns, with arguments fixed at the defer line. See closures, named results, panics, os.Exit and cost.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

defer is the statement that lets Go dispense with finally. You open something, then on the next line you schedule its cleanup, and the pair stays together however the function exits. It also has a small set of rules that decide when each piece runs, and most of the odd bugs around it are one of those rules misremembered. I will go through them in the order they bite: execution order, when arguments are evaluated, what a closure sees, how a deferred function changes results, what happens in a panic and what never runs. Every claim is a program with its real output. It belongs in the Go basics path, after functions, and the next article, defer inside a loop, covers the case that causes the most production trouble.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)

Last in, first out

Each defer pushes a call onto a stack belonging to the current function. When the function returns, the stack is popped, so the calls run in reverse order:

main.goGo
package main

import "fmt"

func main() {
	for i := 0; i < 3; i++ {
		defer fmt.Println("deferred loop call", i)
	}

	x := 1
	defer fmt.Println("argument evaluated at the defer line:", x)
	defer func() { fmt.Println("closure reads x when it runs:", x) }()

	x = 2
	fmt.Println("function body finished, x is", x)
}
Text
function body finished, x is 2
closure reads x when it runs: 2
argument evaluated at the defer line: 1
deferred loop call 2
deferred loop call 1
deferred loop call 0

The body prints first. Then the deferred calls run from the last one scheduled to the first, so the closure runs before the Println that was deferred before it, and the three loop calls come last, counting down from 2. Two different results for x appear, and the difference is the next rule.

Arguments now, closure bodies later

At the defer statement, Go evaluates the function value and every argument and stores them. defer fmt.Println("...", x) captured the number 1 at that moment, so changing x afterwards does not affect it. The closure form defers a call to a function with no arguments, and the body reads x when it eventually runs, so it sees 2. The closure follows the capture rules in closures in Go: it holds the variable, not a copy.

This matters for the commonest deferred call after a resource is acquired. In defer f.Close(), f is evaluated at the defer line, so if f is reassigned later, the original file is still the one closed. For defer timer.Stop() the same holds. When you want the final value of a variable, either use a closure or pass a pointer. When you want a snapshot, pass the argument directly.

One method-call detail: with defer obj.Method(), the receiver obj is evaluated at the defer line. For a value receiver, that means the method sees a copy made at that point.

Defer can change what the function returns

If a function has named results, a deferred function runs after the return statement has assigned them, and it can still modify them. The caller receives the modified values:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func double() (n int) {
	defer func() { n *= 2 }()
	n = 21
	return n
}

func unnamed() int {
	n := 21
	defer func() { n *= 2 }()
	return n
}

func wrap() (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("wrap: %w", err)
		}
	}()
	return errors.New("disk full")
}

func main() {
	fmt.Println(double(), unnamed())
	fmt.Println(wrap())
}
Text
42 21
wrap: disk full

double returns 42: return n set the named result to 21, the deferred closure doubled it, and the function returned the doubled value. unnamed returns 21, because its result is a fresh value copied out at the return, and the closure changed a local variable that nobody reads. The third function is the one worth keeping: it wraps every error the function returns in one place. The interplay with named results is a source of confusion and gets its own treatment in named return values and defer. If you shadow err with := in a nested block, the deferred closure does not see it, which is the shadowing trap again.

Panics, recover and what still runs

When a function panics, Go unwinds the stack running deferred calls in each function on the way out. That is what makes cleanup reliable and what lets recover work: a deferred function that calls recover stops the unwinding and the function returns normally.

main.goGo
package main

import (
	"fmt"
	"os"
)

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func main() {
	defer fmt.Println("main's deferred call runs on a normal return")

	fmt.Println(safeDivide(7, 2))
	fmt.Println(safeDivide(7, 0))

	defer fmt.Println("this deferred call is skipped by os.Exit")
	fmt.Println("exiting")
	os.Exit(3)
}
Text
3 <nil>
0 recovered: runtime error: integer divide by zero
exiting
exit status 3

The division by zero panics inside safeDivide, the deferred function recovers it and sets the named result err, and the caller gets a value instead of a crash. Then comes the reminder that deferred calls are a function-level mechanism. os.Exit ends the process immediately, so neither deferred line in main ran, which the output confirms. log.Fatal calls os.Exit(1) after logging, so it skips defers as well. Code that relies on a deferred flush or cleanup before exit must call it explicitly before os.Exit, or return from main and exit through a wrapper. Recovering from panics the right way goes further into where recover belongs, and the answer is rarely "everywhere".

What defer costs

An older rule of thumb says defer is slow. It was true before Go 1.14 and is not now. The compiler "open-codes" most defers, turning them into straight-line code at the function's exits. I measured the commonest use, an unlock, against the manual form:

main_test.goGo
package main

import (
	"sync"
	"testing"
)

var mu sync.Mutex
var counter int

func withDefer() {
	mu.Lock()
	defer mu.Unlock()
	counter++
}

func withoutDefer() {
	mu.Lock()
	counter++
	mu.Unlock()
}

func BenchmarkDefer(b *testing.B) {
	for b.Loop() {
		withDefer()
	}
}

func BenchmarkNoDefer(b *testing.B) {
	for b.Loop() {
		withoutDefer()
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkDefer-10      	83579282	        13.94 ns/op	       0 B/op	       0 allocs/op
BenchmarkDefer-10      	86237618	        13.88 ns/op	       0 B/op	       0 allocs/op
BenchmarkDefer-10      	86991192	        13.82 ns/op	       0 B/op	       0 allocs/op
BenchmarkNoDefer-10    	86701286	        13.81 ns/op	       0 B/op	       0 allocs/op
BenchmarkNoDefer-10    	81377541	        13.91 ns/op	       0 B/op	       0 allocs/op
BenchmarkNoDefer-10    	85792933	        14.64 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	7.375s

Both took about 14 ns per call, and the cost is dominated by the mutex itself. The equal numbers say what to do: use defer mu.Unlock() everywhere, because it makes the unlock survive an early return or a panic, and a missing unlock is the more expensive bug. Open-coding has limits. A defer inside a loop cannot be open-coded, since the number of calls is unknown at compile time, and the compiler falls back to heap-allocated defer records. That path is slower, and the real problem with loops is not speed but timing, as the next article shows.

Idioms worth memorizing

Four defers cover most real code.

defer f.Close() right after a successful open, paired with the error check on the line above, so the close is never forgotten. For files you write, check the error from Close explicitly instead, since a write can fail there (errors from deferred Close calls shows how).

defer mu.Unlock() right after mu.Lock().

defer wg.Done() at the top of a goroutine's body, so the counter is decremented whichever way it ends.

defer func() { ... recover() ... }() at the boundary of a goroutine or a request handler, converting a panic into an error and a log line.

A less common one is the timing helper, which uses the "argument now, body later" rule on purpose. The call to trace("name") runs at the defer line and returns the function to run on exit:

Go
func trace(name string) func() {
	start := time.Now()
	return func() { log.Printf("%s took %v", name, time.Since(start)) }
}

func work() {
	defer trace("work")()
	// ... the rest of the function
}

The extra parentheses are the whole trick: the first call runs now and starts the clock, and the returned function is the one deferred.

Where defer is the wrong tool

Defer is tied to the function, not the block. A resource acquired in a loop body is released when the function ends, which is far too late; the fix is in defer inside a loop. A cleanup that must happen before the function does something else, such as closing a file before renaming it, has to be an explicit call. Deferring work with side effects that should be visible to the caller, such as a transaction commit whose error is ignored, hides failures, and a named result and a check are the answer. And in code that must exit the process with cleanup, structure main to call a run() error function, handle the error and exit once, so defers in run have already fired. The specification on defer statements states these rules exactly, and the Go blog's Defer, Panic, and Recover is the classic walk-through.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.