- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've read a Go stack trace like this at 2am. An error bubbles up
five layers, and the message at the top is just EOF. No file name,
no operation, no clue which of the forty io calls in the request
path produced it. The error is technically correct and completely
useless.
The fix most Go developers reach for is wrapping every return with
fmt.Errorf("read config: %w", err). That works, but a function
with six early returns needs the wrap repeated six times, and the day
someone adds a seventh return without it, the trail goes cold again.
There is a tidier pattern. A single defer can wrap every error a
function returns, on every path, including the ones you forgot. It
hinges on one Go feature that a lot of people know exists but rarely
connect to this use: named return values.
The named-return mechanic
A Go function can name its results in the signature:
func loadConfig(path string) (cfg Config, err error) {
// cfg and err are ordinary variables here,
// already declared, already zeroed.
...
}
cfg and err are real variables in the function body, initialized
to their zero values. When you write return someCfg, someErr, Go
assigns someErr to err and someCfg to cfg, and only then does
the function actually return.
The order matters, because deferred functions run in that gap. The
sequence on any return is:
- Assign the returned expressions to the named variables.
- Run deferred functions, in LIFO order.
- Hand the current value of the named variables back to the caller.
Step 2 sits between the assignment and the handoff. A deferred
closure that captures err can read what the function is about to
return, change it, and the caller sees the changed value. That is the
whole trick.
Wrapping every error from one place
Here is the pattern applied to error context:
func loadConfig(path string) (cfg Config, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("loadConfig %q: %w", path, err)
}
}()
f, err := os.Open(path)
if err != nil {
return Config{}, err
}
defer f.Close()
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
Every non-nil error leaving this function now carries the same
prefix and the path, whether it came from os.Open or the JSON
decode. Add a tenth return path next year, and it gets wrapped too,
because the wrapping lives in one deferred closure instead of at each
return.
Two details are load-bearing. The if err != nil guard keeps the
happy path clean, otherwise you would wrap nil into a non-nil
*fmt.wrapError and break every err != nil check downstream. And
%w preserves the chain, so errors.Is and errors.As still reach
the original sentinel:
_, err := loadConfig("/etc/app.json")
if errors.Is(err, os.ErrNotExist) {
// still true through the wrap
}
Rescuing a cleanup error you would otherwise drop
The other place this earns its keep is deferred cleanup that can
itself fail. Writing a file is the canonical case. Close on a
writable file flushes buffers, and that flush can return an error
that means "your data did not actually land on disk." The lazy
defer f.Close() throws that error away.
func writeReport(path string, r Report) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
return json.NewEncoder(f).Encode(r)
}
Read the deferred closure carefully. It closes the file, and if the
close fails and the function was otherwise going to return nil,
it promotes the close error into the return value. If the encode
already failed, that original error wins, because a flush failure on
top of an encode failure is noise. You keep the first, most specific
error and still never leak the descriptor.
Why the unnamed version silently does nothing
Now the part that trips people up. Try the same closure without
naming the result:
func loadConfig(path string) (Config, error) {
var err error
defer func() {
if err != nil {
err = fmt.Errorf("loadConfig: %w", err)
}
}()
f, err := os.Open(path)
if err != nil {
return Config{}, err
}
...
}
This compiles. It also does nothing useful. The return Config{}, err
copies the current value of err into the function's anonymous
result slots and returns. The deferred closure then runs and reassigns
the local err, but that local is no longer connected to anything the
caller receives. The result was already copied out. You are editing a
variable whose value has left the building.
With named returns the deferred closure and the return statement
write to the same variable, so the mutation is visible. Without
them, they write to two different places, and the change evaporates.
Same code shape, opposite behavior, no compiler warning. This is the
single reason the pattern requires named returns, and the reason it
looks like magic until you have seen the three-step return sequence.
Naked returns are a separate choice
Named returns let you write a bare return with no operands, which
sends back whatever the named variables currently hold. People
conflate that with the defer pattern, but they are independent.
func loadConfig(path string) (cfg Config, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("loadConfig %q: %w", path, err)
}
}()
f, err := os.Open(path)
if err != nil {
return // naked: returns zero cfg + current err
}
...
}
The wrapping works exactly the same with explicit return Config{}, err.
Naked returns just read shorter. In a long function they read
worse, because a reader has to scroll up to learn what a bare
return actually sends back. Use named returns for the defer
mechanic; reach for explicit return operands anyway unless the
function is short enough to read in one screen.
The traps to keep it careful
The pattern is safe when you respect three things.
Do not shadow the named result. f, err := os.Open(path) inside
an inner block with := can declare a fresh err that the deferred
closure never sees. At function scope the := reuses the named err
because it is already declared, which is what you want. Inside an
if or for block, := makes a new one. go vet -vettool shadow
checks and golangci-lint's shadow analyzer catch most of these.
Recover in the same closure if you want to convert a panic. Since
the deferred closure already owns err, it is the natural spot to
turn a panic into an error return:
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("loadConfig panicked: %v", r)
}
}()
Do this only at a real boundary, not as a blanket panic swallow.
Keep it to boundaries, not every function. Wrapping in a deferred
closure on a five-line helper is more machinery than the code earns.
It pays off in functions with several return paths, or where a
cleanup error would otherwise be lost. Everywhere else, a plain
fmt.Errorf at the one return that can fail is clearer.
What to take to your codebase
Grep for two shapes. First, defer f.Close() on anything opened for
writing — each one is a place a flush error is being dropped, and a
named-return closure recovers it. Second, functions that repeat the
same fmt.Errorf("thing: prefix at three or more returns — those
collapse into a single deferred wrap and stop drifting out of sync
when someone adds a path.
The mechanic is small. The reason it works is the exact ordering of
assign, defer, return that Go guarantees on every function exit. Once
that sequence is in your head, the pattern stops looking clever and
starts looking obvious.
If the assign-defer-return ordering is the kind of language detail
you want nailed down rather than half-remembered, The Complete Guide
to Go Programming walks the runtime mechanics (defer, named
results, panic and recover) from the spec up. Hexagonal Architecture
in Go is the companion for keeping this at the right boundary, so
error wrapping lives in your adapters instead of smeared through every
layer.

Top comments (0)