Skip to content

Defer and recover: what go vet and gofmt catch

Level: 201 · anyone who runs go vet and wants to know which defer mistakes it can see

One line: Four go vet analyzers touch this chapter — lostcancel, unreachable, unusedresult and copylocks — and the mistakes that matter most, a defer in a loop, a Close deferred before the error check and a recover in the wrong place, compile, pass vet, and are still wrong.

Each entry runs a bad program through go vet, shows the finding and the exit status (vet exits 1 on a finding), and the version vet accepts. gofmt has nothing to say about defer beyond layout. The one compiler flag that is a lens on this chapter is -gcflags=-d=defer, which prints how each defer is compiled — the transcript is in What a defer costs.

lostcancel: a cancel function not called on every path

Verified output of defer_vet_lostcancel_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet lost.go
# command-line-arguments
# [command-line-arguments]
./lost.go:9:2: the cancel function is not used on all paths (possible context leak)
./lost.go:11:3: this return statement may be reached without using the cancel var defined on line 9
exit status 1
$ go vet kept.go
exit status 0

The warning means that some path out of fetchOrder — the early return on line 11 — leaves without calling cancel, and the context and whatever waits on it live on. context.WithCancel ↗ states the rule: code should call cancel as soon as the operations running in the context complete.

The good version is kept.go in the transcript: defer cancel() on the line after WithCancel, which covers every path. A deadline is a cancel with a clock uses it throughout.

When the tool is right, and when it is noise. Right nearly always. It is noise when the cancel is handed to another goroutine or stored to be called later — vet reports a cancel it cannot follow — and the fix is to make the call visible, or to keep the cancel with the context in a struct that owns both.

unreachable: code after panic or return

Verified output of defer_vet_unreachable_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet after_panic.go
# command-line-arguments
# [command-line-arguments]
./after_panic.go:8:3: unreachable code
exit status 1

The warning means that the Println after panic can never run: panic is a terminating statement, and the analyzer knows the ones the spec ↗ lists — return, goto, panic, an endless for.

The good version moves the message before the panic, or into its argument:

panic(fmt.Sprintf("rejected quantity %d: must be positive", quantity))

When the tool is right, and when it is noise. Right when the dead line is a leftover; noise mid-refactor, when a panic("unreachable") guards a branch you are about to fill. It says nothing after os.Exit or log.Fatal, which are calls, not terminating statements — code after those is just as dead.

unusedresult: fmt.Errorf whose result is dropped

Verified output of defer_vet_unusedresult_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet dropped.go
# command-line-arguments
# [command-line-arguments]
./dropped.go:11:4: result of fmt.Errorf call not used
exit status 1
$ go vet assigned.go
exit status 0
$ go run assigned.go
saving order 18: disk full

The warning means that the wrapping error was built and thrown away. The deferred closure meant to change the named result and forgot the assignment, so the caller sees the bare disk full. The analyzer's list covers fmt.Errorf, fmt.Sprintf, errors.New, the context.With... functions and the Error and String methods (unusedresult ↗).

The good version is assigned.go: err = fmt.Errorf(...), and the run prints saving order 18: disk full — the idiom of A defer can change a named result.

When the tool is right, and when it is noise. Right every time for the functions on its list: none of them has a side effect worth calling it for. It is silent for your own functions unless you add them with -unusedresult.funcs.

copylocks: a deferred Unlock on a copy of the lock

Verified output of defer_vet_copylocks_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet by_value.go
# command-line-arguments
# [command-line-arguments]
./by_value.go:13:9: Post passes lock by value: command-line-arguments.Ledger contains sync.Mutex
exit status 1
$ go vet by_pointer.go
exit status 0
$ go run by_pointer.go
1999

The warning means that Post has a value receiver, so each call works on a copy of the Ledger — a copy of the Mutex and of the balance. The Lock and the deferred Unlock act on the copy; two goroutines calling Post share nothing and guard nothing, and the balance printed by main is the original's, untouched. The finding points at the receiver, line 13.

The good version is by_pointer.go: a pointer receiver, and the run prints 1999. A mutex guards a counter is the same rule with goroutines contending.

When the tool is right, and when it is noise. Right whenever a sync.Mutex, RWMutex, WaitGroup or Once is copied — by a value receiver, an assignment, a range variable or a function argument. It is rarely noise; a struct that must be copied should hold a pointer to its lock.

What no tool catches

  • A defer in a loop. Every Close waits for the function to return, and the transcript in A defer in a loop runs at function end shows go vet exiting 0 on it. staticcheck's SA9001 ↗, revive's defer rule ↗ (loop) and go-critic's deferInLoop ↗ report it; none runs here.
  • defer rows.Close() before checking the error. When Query fails, rows is nil, and the deferred Close dereferences it on the way out — the transcript below. The order is the fix: check err, then defer. staticcheck's SA5001, "Deferring Close before checking for a possible error" ↗ names it. An *os.File is gentler — its Close on a nil receiver returns os.ErrInvalid instead of panicking — so there defer f.Close() before the check only hides the mistake.
  • recover in the wrong place. defer recover(), and a helper that calls recover for the deferred function, both compile and pass vet, and neither stops a panic — recover works only in the deferred call, cases 3 and 4. revive's defer rule (immediate-recover, recover) flags both.
  • A dropped Close error after a write. defer f.Close() on a file you wrote to discards the error Close may return; unusedresult does not list Close. A defer can change a named result shows the closure that keeps it; errcheck ↗, in golangci-lint's default set, reports the dropped error.

The second of those, run — vet silent, then the panic, with the deferred frame that raised it:

Verified output of defer_vet_silent_nil_close_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet close_before_check.go
exit status 0
$ go build close_before_check.go
$ ./close_before_check
counting orders on a database that is down
panic: runtime error: invalid memory address or nil pointer dereference
deferred call that panicked: database/sql.(*Rows).Close
exit status 2
defer_vet_silent_nil_close_sh.sh

defer_vet_silent_nil_close_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# What no tool in the distribution catches: `defer rows.Close()` written
# before the error from Query is checked. When Query fails, rows is nil and
# the deferred Close dereferences it as the function returns. vet is silent;
# the program panics. The connector stands in for a database that is down.
# The panic's second stderr line names a signal and an address that vary, so
# only the panic line and the frame that panicked are kept.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/close_before_check.go" <<'GO'
package main

import (
    "context"
    "database/sql"
    "database/sql/driver"
    "errors"
    "fmt"
)

// downConnector fails every connection attempt, as a database that is down would.
type downConnector struct{}

func (downConnector) Connect(context.Context) (driver.Conn, error) {
    return nil, errors.New("database is down")
}

func (downConnector) Driver() driver.Driver { return nil }

func countOrders(db *sql.DB) (int, error) {
    rows, err := db.Query("SELECT COUNT(*) FROM orders")
    defer rows.Close() // rows is nil whenever err is not
    if err != nil {
        return 0, err
    }
    var n int
    for rows.Next() {
        if err := rows.Scan(&n); err != nil {
            return 0, err
        }
    }
    return n, rows.Err()
}

func main() {
    db := sql.OpenDB(downConnector{})
    fmt.Println("counting orders on a database that is down")
    n, err := countOrders(db)
    fmt.Println(n, err)
}
GO

say() { printf '$ %s\n' "$*"; }
cd "$dir" || exit 1

say 'go vet close_before_check.go'
go vet close_before_check.go 2>&1
echo "exit status $?"

say 'go build close_before_check.go'
go build close_before_check.go || exit 1
say './close_before_check'
./close_before_check 2>stderr.txt
status=$?
grep '^panic:' stderr.txt
grep -o 'database/sql\.(\*Rows)\.Close' stderr.txt | head -n 1 | sed 's/^/deferred call that panicked: /'
echo "exit status $status"

Sources