Skip to content
gohacks.dev
Basics

Named Return Values and Error Handling in Go Explained

How named results work with defer in Go: a deferred function can change the returned error, the shadowing trap, naked returns and when to use named results.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

Most Go functions declare their results by type alone: func Read() ([]byte, error). Go also lets you name them, func Read() (data []byte, err error), and a function with named results behaves a little differently, because the names are variables you can read and write inside the function. The feature looks like documentation and acts like a mechanism. Its best use is the interaction with defer, where it allows something nothing else in the language does: changing what the function returns after the return statement has run.

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

This article explains the order of events, shows the three patterns that use it, then covers the traps. It is a companion to errors from deferred Close calls and belongs to the error handling guide. Everything ran on Go 1.25.6.

What a named result is

A named result is a local variable. It exists from the start of the function, holds the zero value of its type until you assign something, and its final value is what the caller receives:

main.goGo
package main

import "fmt"

func divide(a, b int) (quotient int, err error) {
	if b == 0 {
		err = fmt.Errorf("divide %d by zero", a)
		return
	}
	quotient = a / b
	return
}

func main() {
	fmt.Println(divide(7, 2))
	fmt.Println(divide(1, 0))
}
Text
3 <nil>
0 divide 1 by zero

The bare return statements are naked returns: they return the current values of the named results. In the zero-division case, quotient was never assigned and the caller gets 0 alongside the error, which is the zero value, and which matches the usual rule that a result is meaningless when the error is non-nil. Naked returns are legal in short functions and a readability problem in long ones, because the reader has to scroll up to learn what is being returned.

The order of events

The part that matters for defer is the sequence a return x statement follows:

  1. The value x is assigned to the named result.
  2. Deferred functions run, in last-in first-out order.
  3. The function returns to the caller, with whatever the named result holds by then.

Because step 2 comes after step 1 and before step 3, a deferred closure that assigns to the result changes what the caller sees:

main.goGo
package main

import "fmt"

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

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

func main() {
	fmt.Println(f(), g())
}
Text
42 21

f returns 42 and g returns 21. In f, return 21 stores 21 in n, the deferred closure doubles n, and the caller receives the doubled value. In g, the result is the anonymous return slot; return n copies 21 into it before the deferred function runs, so doubling the local n afterwards changes nothing the caller sees. The difference is the whole feature. Without a named result there is no variable for the closure to reach.

Pattern 1: report the Close error

The case most programmers meet first is keeping the error from a Close call at the end of a write:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type conn struct{}

func (conn) Send(string) error { return nil }
func (conn) Close() error      { return errors.New("close: connection reset") }

func notify(msg string) (err error) {
	c := conn{}
	defer func() {
		if cerr := c.Close(); cerr != nil && err == nil {
			err = cerr
		}
	}()
	return c.Send(msg)
}

func main() {
	fmt.Println(notify("hello"))
}
Text
close: connection reset

Send succeeded, Close failed, and the caller learns about it. The check on err == nil keeps the earlier failure if there was one. Errors from deferred Close calls covers this pattern, including errors.Join and buffered writers.

Pattern 2: turn a panic into an error

A deferred function is also the only place where recover works, and a named result gives it a way to hand the outcome back:

main.goGo
package main

import (
	"fmt"
)

func safeIndex(xs []int, i int) (v int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("index %d: %v", i, r)
		}
	}()
	return xs[i], nil
}

func main() {
	fmt.Println(safeIndex([]int{1, 2, 3}, 1))
	fmt.Println(safeIndex([]int{1, 2, 3}, 9))
}
Text
2 <nil>
0 index 9: runtime error: index out of range [9] with length 3

The second call panics with an index out of range, the deferred function recovers, and err is set, so the caller gets an ordinary error instead of a crash. The value result stays at its zero value. Use this at a boundary where a panic from lower-level code must not kill the process, and not as a general way to avoid checking indexes (recover done right draws that line).

Pattern 3: add the same context to every error

A function with several return nil, err statements can add one phrase to all of them with a deferred closure:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func step(name string, fail bool) error {
	if fail {
		return errors.New(name + " failed")
	}
	return nil
}

func deploy(failAt string) (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("deploy: %w", err)
		}
	}()
	for _, s := range []string{"build", "push", "rollout"} {
		if err := step(s, s == failAt); err != nil {
			return err
		}
	}
	return nil
}

func main() {
	fmt.Println(deploy("none"))
	fmt.Println(deploy("push"))
}
Text
<nil>
deploy: push failed

Whichever step fails, the error leaves deploy prefixed with deploy:, and the wrap lives in one place. The pattern is tidy for functions with many exits. Use it sparingly, because the wrap happens away from the return that causes it, which can surprise a reader.

The trap: shadowing the named result

The := operator creates new variables in the current scope. Inside an if or for block, err := f() declares a new err that hides the named result. The compiler has a rule that catches the dangerous form:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func risky() error { return errors.New("boom") }

func run() (err error) {
	if true {
		err := risky()
		if err != nil {
			return
		}
	}
	return
}

func main() {
	fmt.Println(run())
}
Text
# example.test/lab
./main.go:14:4: result parameter err not in scope at return
	./main.go:12:3: inner declaration of var err error

The compiler rejects this with result parameter err not in scope at return, because the bare return inside the block would return an err that has been shadowed and is not the result. That is the lucky case. When you write return err explicitly inside the block, the code compiles and works, since the shadowed err is returned by value. The unlucky case is shadowing in a block that does not return, then relying on the named err afterwards:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func risky() error { return errors.New("boom") }

func run() (err error) {
	if true {
		err := risky()
		_ = err
	}
	return
}

func main() {
	fmt.Println(run())
}
Text
<nil>

This prints <nil>. The failure was assigned to an inner err that disappeared at the end of the block, and the named result was never touched. The compiler is silent. The cure is to use = and not := when you mean to assign to the named result, which sometimes needs a separate declaration for the other variable (var x int; x, err = f()). The shadow analyzer and several linters flag these (variable shadowing).

A habit worth keeping

Whenever you write a deferred closure that touches a named result, put it directly under the declaration it cleans up, and keep the function short enough that the whole mechanism is visible at once. The reader should see the open, the defer and the first use together, so they can reason about the order without scrolling. If the function grows past a screen, extract the part that needs the defer into its own small function and give it a clear name.

When to name results

A short rule keeps the feature in its place.

  • Name them when a deferred function needs them. That is the strongest reason and it is what the patterns above do.
  • Name them to say which is which when two results have the same type: func Split(s string) (head, tail string) is clearer than (string, string), and appears in the generated documentation.
  • Skip naked returns unless the function is a few lines long. return value, nil states what is returned; return makes the reader work.
  • Don't name results just to avoid declaring a variable. It adds nothing and exposes the variable in the signature.

The language specification covers the order precisely in its section on defer statements, and the Go blog's Defer, Panic, and Recover walks through the same examples from the other direction.

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.