33. Errors are values

📖 Reading · 10 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Go has no exceptions for ordinary failure. A function that can fail returns an error alongside its result, and the caller deals with it on the next line. That's it — there is no invisible control flow anywhere in a Go program.

The error type

You've already seen it: error is an interface with one method.

type error interface {
    Error() string
}

Nothing more. An error is any value that can describe itself as a string, and nil means "no error".

Creating errors

package main

import (
    "errors"
    "fmt"
)

func withdraw(balance, amount int) (int, error) {
    if amount <= 0 {
        return balance, errors.New("amount must be positive")
    }
    if amount > balance {
        return balance, fmt.Errorf("insufficient funds: have %d, need %d", balance, amount)
    }
    return balance - amount, nil
}

func main() {
    b, err := withdraw(100, 30)
    fmt.Println(b, err)

    b, err = withdraw(100, 500)
    fmt.Println(b, err)

    b, err = withdraw(100, -5)
    fmt.Println(b, err)
}

Two ways to make one:

  • errors.New("...") for a fixed message.
  • fmt.Errorf("... %d ...", x) when you want to include values. It's Sprintf that returns an error.

Both give you a value you can return, store, compare and pass around, which is the whole point of the design.

The shape of every Go call site

package main

import (
    "fmt"
    "strconv"
)

func parseAll(inputs []string) ([]int, error) {
    nums := make([]int, 0, len(inputs))
    for i, s := range inputs {
        n, err := strconv.Atoi(s)
        if err != nil {
            return nil, fmt.Errorf("input %d: %w", i, err)
        }
        nums = append(nums, n)
    }
    return nums, nil
}

func main() {
    nums, err := parseAll([]string{"1", "2", "3"})
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println("parsed:", nums)

    nums, err = parseAll([]string{"1", "oops", "3"})
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println("parsed:", nums)
}

if err != nil { return ..., err } is the Go idiom. People complain that it's verbose; the trade is that failure is visible in the source, right where it happens, instead of hidden behind a try block three frames up.

Two habits to build now:

  • Check immediately. Never use a result before checking its error — on failure it's usually a zero value or nil, and you'll get a confusing panic instead of a clear message.
  • Return the zero value with the error. return nil, err for slices and pointers, return 0, err for numbers. Don't return half-built results.

Handling, not just propagating

Returning the error up isn't the only option. Sometimes you handle it:

package main

import (
    "fmt"
    "strconv"
)

func parseOr(s string, fallback int) int {
    n, err := strconv.Atoi(s)
    if err != nil {
        return fallback
    }
    return n
}

func mustParse(s string) int {
    n, err := strconv.Atoi(s)
    if err != nil {
        panic("bad literal: " + s)
    }
    return n
}

func main() {
    fmt.Println(parseOr("42", 0))
    fmt.Println(parseOr("nonsense", -1))
    fmt.Println(mustParse("7"))
}

Three legitimate responses to an error: handle it (a default, a retry, a different source), return it (usually with context added), or crash (only for programmer error — see the panic lesson).

The one response that isn't legitimate is ignoring it. n, _ := strconv.Atoi(s) compiles happily and quietly gives you 0 for garbage input.

Errors are just data

Because they're ordinary values, you can put them in a slice, count them, or collect them all instead of stopping at the first:

package main

import (
    "fmt"
    "strconv"
)

func parseAll(inputs []string) ([]int, []error) {
    var nums []int
    var errs []error
    for _, s := range inputs {
        n, err := strconv.Atoi(s)
        if err != nil {
            errs = append(errs, fmt.Errorf("%q is not a number", s))
            continue
        }
        nums = append(nums, n)
    }
    return nums, errs
}

func main() {
    nums, errs := parseAll([]string{"1", "two", "3", "four"})

    fmt.Println("parsed:", nums)
    fmt.Println("failures:", len(errs))
    for _, e := range errs {
        fmt.Println(" -", e)
    }
}

Form validation wants every problem at once, not just the first. Try expressing that with exceptions and you'll appreciate errors-as-values.

Error message style

Go has strong conventions here, and they exist because messages get concatenated as they travel up:

  • Lowercase, no trailing punctuation. "file not found", not "File not found." — because it will end up in the middle of a longer message.
  • State what failed, with the specifics. "parsing %q: %v" beats "an error occurred".
  • Don't start with "error:" or "failed to" — the caller adds that framing.
package main

import (
    "errors"
    "fmt"
)

func main() {
    bad := errors.New("Error: Could not open the file!")
    good := fmt.Errorf("open config.yaml: file does not exist")

    fmt.Printf("reading settings: %v\n", bad)
    fmt.Printf("reading settings: %v\n", good)
}

Read both output lines out loud. The second is a sentence; the first is a collision.

Your turn

Write safeDivide(a, b int) (int, error) that returns an error with the message cannot divide by zero when b is zero. Print both cases:

5 <nil>
0 cannot divide by zero
package main

import (
    "errors"
    "fmt"
)

// write safeDivide here

func main() {
    q, err := safeDivide(10, 2)
    fmt.Println(q, err)

    q, err = safeDivide(10, 0)
    fmt.Println(q, err)
}
package main

import (
    "errors"
    "fmt"
)

func safeDivide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

func main() {
    q, err := safeDivide(10, 2)
    fmt.Println(q, err)

    q, err = safeDivide(10, 0)
    fmt.Println(q, err)
}

Next: how a caller tells which error it got.