- 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 open a Go file you didn't write. A function signature reads
func parse(r io.Reader) (n int, err error). Two lines down, the
body is forty lines long, three branches deep, and it ends with a
bare return. Now you have to trace every path to figure out what
n and err actually hold when that return fires.
That's named return values doing their worst. But the same feature,
used in the right spot, is the cleanest way to modify a return value
from a defer. Go's own standard library uses named returns in
exactly two situations and avoids them everywhere else. Knowing which
is which is the whole game.
What named returns actually are
A named return declares the result variables in the signature. They
start at their zero value and live for the whole function body.
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}
Two things happen here. x and y are declared for you, and the
bare return at the end sends back whatever they hold. That trailing
return with no operands is a naked return.
For a function this short, it reads fine. The trouble starts when the
body grows and the naked return stops telling you what it sends back.
Where they help #1: modifying the result in a defer
This is the case named returns were built for. A deferred function
can read and write the named
result after the return statement has set it but before the caller
sees it. You cannot do that with positional returns.
The canonical use is recovering from a panic and turning it into an
error:
func safeParse(b []byte) (result Config, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse panicked: %v", r)
}
}()
return decode(b), nil
}
If decode panics, the deferred closure runs, recover catches it,
and it assigns to the named err. The caller gets a real error
instead of a crashed process. Without the name, the defer has
nothing to write to.
The standard library leans on this. database/sql uses it to make
sure a transaction rolls back and the error propagates. Here's the
same shape you'd write yourself:
func withTx(db *sql.DB, fn func(*sql.Tx) error) (err error) {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
return
}
err = tx.Commit()
}()
return fn(tx)
}
The defer inspects the named err. If the callback failed, it
rolls back. If it succeeded, it commits and lets the commit error
become the function's error. That read-then-write dance only works
because err is named.
Where they help #2: documenting the signature
When a function returns two values of the same type, the names carry
meaning the types can't.
func Copy(dst Writer, src Reader) (written int64, err error)
Compare that to (int64, error). The reader of the godoc now knows
the int64 is a byte count, not an index or an ID. io.Copy is
declared exactly this way in the standard library for that reason.
Same story for the boundary case everyone confuses:
func bounds(x int) (low, high int)
(int, int) tells you nothing about order. (low, high int) tells
you which is which. This is a documentation win only. You should
still write the values out and use an explicit return low, high.
Where they bite #1: the naked return in a long body
The moment a named-return function grows past a screen, the naked
return becomes a puzzle. Go's own style guidance says naked returns
are acceptable in short functions and a problem in long ones.
func loadUser(id string) (u User, err error) {
row := db.QueryRow(q, id)
if err = row.Scan(&u.ID, &u.Name); err != nil {
return // what's u here?
}
u.Roles, err = loadRoles(id)
if err != nil {
return // and here?
}
u.Active = true
return // and here?
}
Every return in that function is naked, and every one sends back a
different combination of u and err. To review it you have to
replay the whole body in your head. Write the values explicitly and
the guesswork disappears:
func loadUser(id string) (User, error) {
var u User
row := db.QueryRow(q, id)
if err := row.Scan(&u.ID, &u.Name); err != nil {
return User{}, err
}
roles, err := loadRoles(id)
if err != nil {
return User{}, err
}
u.Roles = roles
u.Active = true
return u, nil
}
Now each exit point states exactly what it returns. The named
version saved you nothing except a few keystrokes, and it cost the
next reader real time.
Where they bite #2: shadowing the named result
This is the subtle one. A named result is a variable in the function
scope. Open an inner block and declare a same-named variable with
:=, and you shadow the result. The outer one keeps its zero value,
and a naked return sends back the wrong thing.
func fetch(id string) (data []byte, err error) {
if id != "" {
data, err := readCache(id) // := shadows both
if err == nil {
return data, err // returns the inner ones, fine
}
}
return // returns the OUTER data, err: nil, nil
}
The inner data, err := creates new variables scoped to the if
block. The explicit return data, err inside the block works because
it names the inner ones. But the trailing naked return at the
bottom refers to the outer data and err, which were never
touched. On the cache-miss path this function returns nil, nil
regardless of what actually happened.
go vet does not flag this by default. The shadow analyzer that
catches it is off by default because it's noisy. So the bug ships,
passes tests that only exercise the cache-hit path, and fails in
production on the first miss.
The fix is to not shadow. Assign to the named results instead of
redeclaring:
func fetch(id string) (data []byte, err error) {
if id != "" {
data, err = readCache(id) // = assigns, no shadow
if err == nil {
return data, err
}
}
return nil, errNotCached
}
One character. := became =. That's the whole bug and the whole
fix. Which is exactly why it slips through review.
A rule you can apply on Monday
You don't need a philosophy. You need three lines.
-
Name the results when a
deferhas to modify them. Panic recovery, transaction rollback, cleanup that changes the returned error. This is the case named returns exist for. Use them. -
Name the results for documentation when two returns share a
type, then still write explicit
returnstatements. Names for the reader of the godoc, explicit values for the reader of the body. -
Everywhere else, return positional values explicitly. No
names, no naked returns. If a function has one
errorand one real value and nodefertouching them,(User, error)beats(u User, err error)every time.
The naked return is the tell. If you named your results only so you
could write a bare return at the bottom of a forty-line function,
delete the names and spell out what you send back. If you named them
so a defer could reach in and fix the error, keep them.
Go gave you a feature with two good uses and a wide blast radius. The
standard library treats it that way. So should your code.
If this was useful
Named returns are a small corner of Go with sharp edges, and they're
a good proxy for the whole language: a feature that reads simple,
behaves precisely, and punishes the assumption you didn't check. The
Complete Guide to Go Programming goes deep on how the compiler
treats result variables, defer timing, and the scoping rules that
turn a := into a shadowing bug. Hexagonal Architecture in Go
shows how to keep signatures like these honest at the boundaries of a
service, where a wrong return value becomes a wrong response.

Top comments (0)