DEV Community

Cover image for Named Return Values in Go: When They Help and When They Bite
Gabriel Anhaia
Gabriel Anhaia

Posted on

Named Return Values in Go: When They Help and When They Bite


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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

(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?
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Name the results when a defer has to modify them. Panic recovery, transaction rollback, cleanup that changes the returned error. This is the case named returns exist for. Use them.
  2. Name the results for documentation when two returns share a type, then still write explicit return statements. Names for the reader of the godoc, explicit values for the reader of the body.
  3. Everywhere else, return positional values explicitly. No names, no naked returns. If a function has one error and one real value and no defer touching 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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)