Go from the ground up

Errors as values

Errors are ordinary values that travel beside results, wrapped with context as they move up, and compared with the tools that understand wrapping.

Part 7 of 12

Intermediate16 min readBack to Courses

error is an interface

error is a built-in interface with one method: Error() string. Any value with that method is an error — a type you make, or the result of errors.New("something broke"). Nothing is thrown; a function that can fail returns an error as its last value, and the caller chooses what to do.

The shape is always the same: value, err := doIt(), then if err != nil { … }. It is more lines than a try block, and it is the reason a Go program reads top to bottom with no invisible jumps: every failure path is a branch you can see.

Wrap it as it travels

A function that cannot fix an error should add context and pass it up. fmt.Errorf("loading config: %w", err) builds a new error that carries the original inside it — the %w verb is the wrapping. As the error moves through layers, the message becomes a trail: what failed, and inside that, why.

That trail is machine-readable. errors.Is(err, os.ErrNotExist) asks whether the error is, or wraps, a particular sentinel error. errors.As(err, &target) pulls out an error of a specific type, so the caller can read a field from it. Premature string matching — comparing messages — is what these two replaced.

Writing the message

Write the message as a phrase that reads well after the next wrapper adds its own. open config.yaml: no such file becomes loading config: open config.yaml: no such file, which is a sentence. failed becomes loading config: failed, which is not. Lowercase, no trailing punctuation, and the context the caller does not already have — that is the whole style. If the caller will log it, the path or the key belongs in the message; if the caller will branch on it, the sentinel belongs in the chain.

When to panic

panic stops the program with a stack trace, and recover can catch it — but the two are for bugs, not for expected trouble. An index out of range panics; a file that is missing returns an error. The line between them is whether a reasonable program could have handled it.

So a library rarely panics, and almost never recovers. A program that must not fall over — a server handling requests — recovers at its top boundary, logs the panic, and keeps serving. Everywhere else, the error return is the tool. A panic in a goroutine that nobody recovers takes the whole program down, not just that goroutine.

Returned, wrapped, inspected

package main

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

var ErrEmpty = errors.New("nothing to read")

func parseAge(text string) (int, error) {
	if text == "" {
		return 0, ErrEmpty
	}
	age, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("age %q: %w", text, err)
	}
	if age < 0 {
		return 0, fmt.Errorf("age %d is not real", age)
	}
	return age, nil
}

func main() {
	for _, input := range []string{"36", "", "abc", "-2"} {
		age, err := parseAge(input)
		switch {
		case err == nil:
			fmt.Println("ok:", age)
		case errors.Is(err, ErrEmpty):
			fmt.Println("skipped: no input")
		default:
			fmt.Println("failed:", err)
		}
	}
}

A reader that survives bad lines

Run it and read the four outcomes. Then add a line with text "twelve" to the list, and change the program to keep the good values in a slice and report how many lines failed.

Output
Click "Run" to execute your code

Return the error; do not print and continue. A function that logs a failure and hands back an empty value makes the caller believe the work was done — and that is the bug the next chapter of your life will be spent finding.

The chain an error travels

Each %w adds a link. The new error prints your context and keeps the original reachable through its Unwrap() error method. errors.Is walks that chain comparing every link to a target, and errors.As walks it looking for a type it can assign to the pointer you passed.

That is why wrapping is not decoration. If loadConfig returns open config.yaml: no such file, the caller can still ask errors.Is(err, fs.ErrNotExist) and branch on the cause, while the message keeps the path for the human reading the log.

Custom errors are ordinary types with an Error() string method. Make the pointer type the error when it carries state, and return it as error. Since Go 1.20, errors.Join keeps several failures in one value — useful for validation, where every bad field should be reported rather than only the first.

A worked wrap

Follow one failure up the stack. strconv.Atoi("abc") returns a strconv.NumError whose message is strconv.Atoi: parsing "abc": invalid syntax. parseAge wraps it with fmt.Errorf("age %q: %w", text, err), so the message becomes age "abc": strconv.Atoi: parsing "abc": invalid syntax, and the original is still reachable through Unwrap. A caller can print the whole trail for a human and still ask errors.Is(err, strconv.ErrSyntax) for the cause. That is the point of wrapping: one value carries both the story and the handle.

Do not compare messages. err.Error() == "not found" breaks the first time the wording changes; use a sentinel with errors.Is, or a type with errors.As. Never return a typed nil pointer as error — the interface is non-nil and the caller's err != nil branch runs. And do not wrap with %v when you meant %w, because the chain ends there.

Try these next

  • Add a second wrapped layer around the parse error and print errors.Unwrap twice.
  • Define a FieldError with a Field string, return it, and pull it out with errors.As.
  • Collect two bad fields with errors.Join and print the result, then inspect each one.

Report the field that failed

Run it and read the four outcomes. Then add an email check that returns its own `FieldError`, and make one case fail two checks so you can decide which error should win.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

Where does an error appear in a function's results?