Skip to content

A defer can change a named result

Level: 301 · anyone who wraps every error in a function by hand, or whose defer f.Close() loses its error

One line: A return statement sets the result parameters first and runs the deferred calls second, so a deferred closure that names a result can still change what the caller receives — an error wrapped, a panic turned into an error, a Close error that would otherwise be lost — while a closure over a plain local changes nothing the caller sees.

doubledNamed and doubledUnnamed defer the same n *= 2; only one of them returns 42. saveOrder wraps its error in one deferred closure instead of at every return. totalCents turns a panic from a helper into an error. writeOrders reports the error from a deferred Close — an early Close makes the deferred one fail, on every machine.

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

doubledNamed() = 42, doubledUnnamed() = 21
saveOrder(18, ok):   err = <nil>
saveOrder(19, fail): err = saving order 19: disk full, errors.Is(errDiskFull) = true
totalCents(1999 450) = 2449, err = <nil>
totalCents(1999 12x) = 0, err = totalling prices: bad price "12x"
writeOrders(normal):      err = <nil>
writeOrders(close twice): err != nil = true, errors.Is(os.ErrClosed) = true

Reading the output

42 and 21. The spec ↗ spells out the order: deferred functions execute after any result parameters are set by the return statement but before the function returns to its caller, and a deferred function literal may access and modify the named result parameters before they are returned. In doubledNamed, return 21 set n to 21, then the closure doubled n, then the caller got n. In doubledUnnamed, return n copied 21 into the anonymous result, then the closure doubled the local n, which nobody read again.

saving order 19: disk full, and errors.Is still finds errDiskFull. One closure, deferred at the top, wraps whatever error the function returns on any path; %w keeps the chain intact, as Wrap with %w, unwrap with Is shows. The if err != nil inside it matters: without it a nil error would come back as a non-nil one whose text ends in %!w(<nil>).

totalCents returned 0 and an error, not a panic. mustCents panicked; the deferred closure recovered and set both named results. Without named results there would be nothing to set — the function would return whatever its results held, which after a panic is their zero values and a nil error, the worst of both. recover works only in the deferred call has the rules for where the recover may sit.

writeOrders(close twice) returned an error that errors.Is(os.ErrClosed) recognises. defer f.Close() would have dropped that error on the floor; the closure keeps it, and its err == nil guard keeps an earlier write error from being overwritten by a later close error, which is usually the less useful of the two. In real code the deferred Close is where some file systems first report a write that failed — the reason close(2) ↗ can return an error at all; the early Close here only makes that happen on every machine.

a_defer_can_change_a_named_result_go.go

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

// A return statement sets the result parameters first and runs the deferred
// calls second, so a deferred closure that names a result can still change
// it: wrap the error, replace it with a recovered panic, or report an error
// from Close that would otherwise be lost. A closure that captured a local
// copy changes nothing the caller sees.
//
//  go build a_defer_can_change_a_named_result_go.go && ./a_defer_can_change_a_named_result_go
package main

import (
    "errors"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func doubledNamed() (n int) {
    defer func() { n *= 2 }() // n is the result: the caller sees 42
    return 21
}

func doubledUnnamed() int {
    n := 21
    defer func() { n *= 2 }() // n is a local: the result was copied out already
    return n
}

var errDiskFull = errors.New("disk full")

// saveOrder wraps whatever error it returns, on every path, in one place.
func saveOrder(id int, fail bool) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("saving order %d: %w", id, err)
        }
    }()
    if fail {
        return errDiskFull
    }
    return nil
}

// mustCents panics on a bad price; totalCents turns that panic into an error.
func mustCents(price string) int {
    n, err := strconv.Atoi(price)
    if err != nil {
        panic(fmt.Sprintf("bad price %q", price))
    }
    return n
}

func totalCents(prices []string) (total int, err error) {
    defer func() {
        if r := recover(); r != nil {
            total, err = 0, fmt.Errorf("totalling prices: %v", r)
        }
    }()
    for _, p := range prices {
        total += mustCents(p)
    }
    return total, nil
}

// writeOrders reports an error from Close through the named result, but only
// when nothing went wrong earlier. The early Close exists only to make the
// deferred Close fail on every machine.
func writeOrders(closeEarly bool) (err error) {
    f, err := os.CreateTemp("", "orders-*.csv")
    if err != nil {
        return err
    }
    defer os.Remove(f.Name())
    defer func() {
        if cerr := f.Close(); err == nil {
            err = cerr
        }
    }()
    if _, werr := f.WriteString("order-18,1999\n"); werr != nil {
        return werr
    }
    if closeEarly {
        return f.Close()
    }
    return nil
}

func main() {
    fmt.Printf("doubledNamed() = %d, doubledUnnamed() = %d\n", doubledNamed(), doubledUnnamed())

    fmt.Println("saveOrder(18, ok):   err =", saveOrder(18, false))
    err := saveOrder(19, true)
    fmt.Printf("saveOrder(19, fail): err = %v, errors.Is(errDiskFull) = %t\n", err, errors.Is(err, errDiskFull))

    for _, prices := range [][]string{{"1999", "450"}, {"1999", "12x"}} {
        total, err := totalCents(prices)
        fmt.Printf("totalCents(%s) = %d, err = %v\n", strings.Join(prices, " "), total, err)
    }

    fmt.Println("writeOrders(normal):      err =", writeOrders(false))
    err = writeOrders(true)
    fmt.Printf("writeOrders(close twice): err != nil = %t, errors.Is(os.ErrClosed) = %t\n", err != nil, errors.Is(err, os.ErrClosed))
}

What to do

  • Name the result you want a deferred closure to change, and assign to it inside the closure; return x still works, and sets it first.
  • Wrap once, in a deferred closure, with %w, when every error out of a function should carry the same context. Guard it with if err != nil.
  • Keep a Close error when you wrote to the file: defer func() { if cerr := f.Close(); err == nil { err = cerr } }(). For a file you only read, defer f.Close() is fine — a read-only close has nothing to report.
  • Make a recovering closure set the error. It runs on panics as well as returns, and a recover that sets nothing leaves the caller with zero values and err == nil.

Practice

Write loadInvoice(name string, lines []string) (total int, err error) that parses item,cents lines with a helper that panics on a bad line, wrapping a sentinel errBadLine in the panic value. Give loadInvoice two deferred closures: one that recovers the panic into err, and one — deferred first, so that it runs last — that prefixes any error with loading <name>:. Show that errors.Is(err, errBadLine) is true for a bad invoice.

Solution

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

// Kata: an invoice loader whose named error is shaped by two deferred
// closures. The one deferred last runs first and turns a panic from a helper
// into an error that wraps a sentinel; the one deferred first runs last and
// adds the invoice's name to any error on the way out.
package main

import (
    "errors"
    "fmt"
    "strconv"
    "strings"
)

var errBadLine = errors.New("bad line")

func mustParse(line string) (item string, cents int) {
    item, price, ok := strings.Cut(line, ",")
    if !ok {
        panic(fmt.Errorf("%w: %q", errBadLine, line))
    }
    cents, err := strconv.Atoi(price)
    if err != nil {
        panic(fmt.Errorf("%w: %q", errBadLine, line))
    }
    return item, cents
}

func loadInvoice(name string, lines []string) (total int, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("loading %s: %w", name, err)
        }
    }()
    defer func() {
        if r := recover(); r != nil {
            total = 0
            if e, ok := r.(error); ok {
                err = e
            } else {
                err = fmt.Errorf("%v", r)
            }
        }
    }()
    for _, line := range lines {
        _, cents := mustParse(line)
        total += cents
    }
    return total, nil
}

func main() {
    good := []string{"widget,1999", "shipping,450"}
    bad := []string{"widget,1999", "shipping 450"}
    for _, inv := range []struct {
        name  string
        lines []string
    }{{"INV-18", good}, {"INV-19", bad}} {
        total, err := loadInvoice(inv.name, inv.lines)
        fmt.Printf("%s: total = %d, err = %v, errors.Is(errBadLine) = %t\n", inv.name, total, err, errors.Is(err, errBadLine))
    }
}

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

INV-18: total = 2449, err = <nil>, errors.Is(errBadLine) = false
INV-19: total = 0, err = loading INV-19: bad line: "shipping 450", errors.Is(errBadLine) = true

In other languages

  • Rust has no named results and no deferred code that could edit a return value; context is added on the way out with ? and map_err, or anyhow's .context(...) — the Rust library's The ? operator ↗ and anyhow and context ↗. A Drop for a file cannot report an error; File::sync_all ↗ before the drop is how a write error is caught.
  • Python can change the result from a finally: block only by executing a return there, which also discards a pending exception — the language reference ↗ says so. The usual shape is except with raise ... from err.
  • C++ destructors cannot change the return value and must not throw; a scope guard that needs to know whether it is running during unwinding asks std::uncaught_exceptions ↗.

Sources

  • The Go specification, Defer statements ↗ — result parameters are set by the return statement before the deferred functions run, and a function literal may modify them.
  • Defer, Panic, and Recover ↗ — the third rule: deferred functions may read and assign to the returning function's named return values.
  • os.File.Close ↗ and os.ErrClosed ↗.
  • Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 5, "Functions" — section 5.8, "Deferred Function Calls", on deferred functions and named results, and section 5.10, "Recover".
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 5, "Functions" — the section "defer".