Featured image of post Go error wrapping without the head-scratching

Go error wrapping without the head-scratching

One letter in a format string decides whether the check you wrote yesterday still works today. Here's the whole of Go's error chain, built up from that letter, with every output taken from a real run.

Errors in Go are just values. A function hands one back alongside its result, you check it with if err != nil, and you decide what to do about it right there. No exception goes flying up the stack looking for a catch block somewhere, and once you’re used to that it’s one of the nicer things about the language, because you can see where things go wrong just by reading the code.

The fiddly part is what happens as an error travels. By the time a failure has come back up through three or four functions, a bare “no such file or directory” isn’t much use to whoever reads the log, so each layer adds a bit of context on the way past: which file, what it was trying to do at the time. That’s called wrapping, and it’s the right instinct. But how you wrap decides whether the code further up can still recognise the original error. Get it wrong by a single letter in a format string and a check that used to work stops matching, with no warning at all, and the program takes the wrong branch.

None of the pieces are complicated on their own. They’re just scattered. Some live in fmt, some in errors, some in io/fs, and they arrived years apart (wrapping itself in Go 1.13, joining in 1.20, a generic helper in 1.26), so plenty of the advice you’ll find online was written before the newer ones turned up. So let’s put the whole chain in one place and build it up a piece at a time.

Here’s what we’re going to do:

  1. wrap an error with %v and then with %w, and see what changes
  2. check for a specific error with errors.Is
  3. pull a typed error back out with errors.As and errors.AsType
  4. declare our own sentinel error and our own error type
  5. make the two classic pointer mistakes deliberately, and see what catches them
  6. carry more than one error at once with errors.Join
  7. decide which errors our package should expose to its callers
  8. add a hint for the user on top, using go/errors

It takes about twenty minutes. Every command and every line of output below came from a real run on Go 1.27.1, against a module that declares go 1.26. That’s the floor, because step 3 uses errors.AsType, which arrived in 1.26. Everything else works back to Go 1.20.

What you need

  • Go 1.26 or newer. go version will tell you.
  • A scratch directory. Nothing here touches anything outside it.

First we make a module to work in:

mkdir -p /tmp/errwrap && cd /tmp/errwrap
go mod init example.com/errwrap
go mod edit -go=1.26

Each step gets its own little program in its own directory, so you can keep them all and run them side by side to compare.

Wrap with %w, not %v

We’ll start with a function that reads a config file and, if that fails, adds a bit of context to the error before handing it back. This is the most common wrap you’ll ever write. To begin with we’ll use %v, which is what most people reach for first because it’s the verb they already know from printing things.

Create wrap/main.go:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func loadConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("loading config: %v", err)
	}
	return data, nil
}

func main() {
	_, err := loadConfig("missing.yaml")
	fmt.Println(err)
	fmt.Println("== ErrNotExist:", err == fs.ErrNotExist)
	fmt.Println("errors.Is ErrNotExist:", errors.Is(err, fs.ErrNotExist))
}

missing.yaml doesn’t exist, so we know the read fails. main prints the error, then asks two questions about it: is it equal to fs.ErrNotExist (the standard library’s “file does not exist” error), and does errors.Is think it matches? Run it:

go run ./wrap
loading config: open missing.yaml: no such file or directory
== ErrNotExist: false
errors.Is ErrNotExist: false

The message is spot on… the file really doesn’t exist, and it says so. But both checks say no. That’s because %v turns the original error into text, drops it into the new message, and throws the error itself away. What loadConfig returns is a brand new error that just happens to contain some familiar words, and there’s nothing left inside it for a check to find.

Now change the %v to %w on the fmt.Errorf line and run it again:

loading config: open missing.yaml: no such file or directory
== ErrNotExist: false
errors.Is ErrNotExist: true

The message is identical, character for character… which is exactly why this one gets through code review so easily. The difference is that %w keeps hold of the original error inside the new one. That’s a wrap, and a run of wraps one inside another is called the error chain.

Check for an error with errors.Is

So what did errors.Is actually do to get that true? It checked the error we gave it, didn’t find a match, unwrapped one layer to get at the error inside, checked that, and kept going until it either found fs.ErrNotExist or ran out of layers.

Notice that == still says false, and it would even without our wrap. os.ReadFile doesn’t return fs.ErrNotExist directly. It returns a *fs.PathError (which carries the operation and the path) with fs.ErrNotExist wrapped inside it. So comparing with == was always going to miss errors from os. Whenever you’re asking “is this that particular error?”, errors.Is is what you want.

You don’t have to rely on remembering that, either. If you use golangci-lint, its err113 linter flags any == or != between two errors and points you at errors.Is instead. It isn’t switched on by default, so it needs adding to your config, but once it’s there it catches exactly the line we wrote above:

wrap/main.go:21:33: do not compare errors directly "err == fs.ErrNotExist", use "errors.Is(err, fs.ErrNotExist)" instead (err113)

Get the details back out with errors.As

errors.Is answers a yes or no question. Sometimes we want more than that: we want the error of a particular type, so we can read its fields. For a missing file, that’s the *fs.PathError we just met, which knows which file it was and what was being done to it. Getting at that is the job of errors.As.

Create as/main.go. It has the loadConfig from before, this time wrapping with %w:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func loadConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("loading config: %w", err)
	}
	return data, nil
}

func main() {
	_, err := loadConfig("missing.yaml")

	var pathErr *fs.PathError
	if errors.As(err, &pathErr) {
		fmt.Println("op:", pathErr.Op)
		fmt.Println("path:", pathErr.Path)
		fmt.Println("cause:", pathErr.Err)
	}

	if pe, ok := errors.AsType[*fs.PathError](err); ok {
		fmt.Println("AsType found:", pe.Path)
	}
}
go run ./as
op: open
path: missing.yaml
cause: no such file or directory
AsType found: missing.yaml

With errors.As, we declare a variable of the type we’re after (here pathErr) and hand over a pointer to it. As walks the chain just like Is does, and when it finds an error of that type it copies it into our variable and returns true.

The second block does that job again with errors.AsType, a generic version added in Go 1.26. Instead of declaring a variable first, we name the type in square brackets and get the value and a true or false straight back. Both find it. The difference shows up when you get the type wrong, which we’ll do deliberately in a couple of steps’ time.

Make your own sentinel and error type

So far we’ve only checked for errors the standard library made. Our own code will want its own versions of both:

  • a sentinel is a single error value, declared once at package level, that callers compare against with errors.Is. fs.ErrNotExist is one.
  • an error type is a struct that implements the error interface, so it can carry fields for callers to read with errors.As. *fs.PathError is one.

Let’s make one of each for a simple config validator. Create own/main.go:

package main

import (
	"errors"
	"fmt"
)

var ErrEmptyConfig = errors.New("config is empty")

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}

func validate(cfg map[string]string) error {
	if len(cfg) == 0 {
		return ErrEmptyConfig
	}
	if cfg["port"] == "" {
		return &ValidationError{Field: "port", Reason: "is required"}
	}
	return nil
}

func load(cfg map[string]string) error {
	if err := validate(cfg); err != nil {
		return fmt.Errorf("loading config: %w", err)
	}
	return nil
}

func main() {
	err := load(map[string]string{})
	fmt.Println(err)
	fmt.Println("empty?", errors.Is(err, ErrEmptyConfig))

	err = load(map[string]string{"host": "localhost"})
	fmt.Println(err)
	if ve, ok := errors.AsType[*ValidationError](err); ok {
		fmt.Println("bad field:", ve.Field)
	}
}

main calls load twice: once with an empty config to trigger the sentinel, and once with a config that has no port to trigger the validation error.

go run ./own
loading config: config is empty
empty? true
loading config: port: is required
bad field: port

Both come back out through the wrap in load. One detail matters for the next step: Error() is defined on *ValidationError (the pointer), not on ValidationError (the value). That means it’s the pointer that counts as an error, so a pointer is what we return, and a pointer is what’s sitting in the chain.

The two pointer mistakes, and what catches them

Now let’s break things on purpose. There are two mistakes people make with errors.As, both to do with pointers, and both compile without complaint and then panic when the code runs.

The first one is forgetting the &. In as/main.go, change errors.As(err, &pathErr) to errors.As(err, pathErr) and run it. The first line of the panic is what matters:

panic: errors: target must be a non-nil pointer

errors.As needs somewhere to put what it finds, and without the & we’ve handed it a nil value instead of the address of our variable.

The second is asking for the value type when the chain holds a pointer. Add these two lines to the end of main in own/main.go:

	var v ValidationError
	fmt.Println("value target:", errors.As(err, &v))
panic: errors: *target must be interface or implement error

This time there is a &, but it points at a ValidationError (no star), and as we saw in the last step, the value type doesn’t implement error. There’s nothing in the chain it could ever match, so As refuses.

The good news is that go vet spots both before anything runs:

go vet ./as ./own
own/main.go:47:31: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
as/main.go:22:5: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type

You don’t even have to remember to run it: go test runs this particular vet check on its own and won’t build your tests while it fails.

errors.AsType goes one better. Its type parameter has to be an error, so the compiler checks the type you’ve named before the program is even built. Swap the two lines you just added for this:

	if _, ok := errors.AsType[ValidationError](err); ok {
		fmt.Println("found it")
	}
own/main.go:46:28: ValidationError does not satisfy error (method Error has pointer receiver)

That’s a compile error, not a vet warning and not a panic, and it tells you exactly what’s wrong. And because AsType takes no target argument at all, there’s no & to forget in the first place. If you’re on Go 1.26 or later it’s what I’d reach for.

Put own/main.go and as/main.go back the way they were before moving on.

Carry more than one error with errors.Join

Sometimes a failure has more than one cause. We try two config files and both are missing, or a save fails and then the cleanup after it fails too. Go 1.20 added two ways to keep all of them in the chain.

Create join/main.go:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

var ErrEmptyConfig = errors.New("config is empty")

func loadAll(paths ...string) error {
	var errs []error
	for _, p := range paths {
		if _, err := os.ReadFile(p); err != nil {
			errs = append(errs, fmt.Errorf("loading %s: %w", p, err))
		}
	}
	return errors.Join(errs...)
}

func main() {
	err := loadAll("base.yaml", "local.yaml")
	fmt.Println(err)
	fmt.Println("not exist?", errors.Is(err, fs.ErrNotExist))

	fmt.Println("nothing to join:", loadAll())

	both := fmt.Errorf("saving: %w (and then %w)", fs.ErrPermission, ErrEmptyConfig)
	fmt.Println(both)
	fmt.Println(errors.Is(both, fs.ErrPermission), errors.Is(both, ErrEmptyConfig))
}

loadAll tries each file, collects every failure in a slice, and hands back the lot with errors.Join.

go run ./join
loading base.yaml: open base.yaml: no such file or directory
loading local.yaml: open local.yaml: no such file or directory
not exist? true
nothing to join: <nil>
saving: permission denied (and then config is empty)
true true

errors.Join prints each error on its own line, and errors.Is and errors.As search all of them. The third line of output is the handy one: joining an empty slice gives back nil, so a loop like loadAll can always return the join, without a separate “did anything actually fail?” check at the end.

The last example is the other route, putting more than one %w in a single fmt.Errorf. It’s useful when you want to write the sentence yourself, and both causes stay in the chain.

Decide what your package exposes

Everything so far has been about making errors checkable. Now we need to decide which ones we actually want callers checking.

As soon as we wrap an error with %w, callers can check for it, and sooner or later someone will. Say our config package wraps fs.ErrNotExist. A caller writes errors.Is(err, fs.ErrNotExist) to spot a missing config, and it works. Six months later we move the config into a database. fs.ErrNotExist stops appearing, their check starts returning false, and the compiler has no way to warn them. The error we wrapped had become part of our package’s API without us ever deciding that it should.

The Go team’s own write-up, Working with Errors in Go 1.13, treats this as a choice to make each time: wrap when you’re happy for callers to depend on the underlying error, and don’t when it’s an implementation detail.

We don’t have to give up the helpful message to get that, though. The trick is to wrap our own sentinel with %w, and include the underlying cause with %v, so it’s in the text but not in the chain. Let’s do that as a proper package this time, so the “caller” really is on the outside. Create expose/config/config.go:

package config

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

// ErrNotFound is returned when no config file exists at the given path.
var ErrNotFound = errors.New("config not found")

// Load reads the config file at path.
func Load(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
	}
	if err != nil {
		return nil, fmt.Errorf("loading config: %v", err)
	}
	return data, nil
}

And expose/main.go, which uses the package as an outside caller would:

package main

import (
	"errors"
	"fmt"
	"io/fs"

	"example.com/errwrap/expose/config"
)

func main() {
	_, err := config.Load("missing.yaml")
	fmt.Println(err)
	fmt.Println("config.ErrNotFound?", errors.Is(err, config.ErrNotFound))
	fmt.Println("fs.ErrNotExist?", errors.Is(err, fs.ErrNotExist))
}
go run ./expose
config not found: open missing.yaml: no such file or directory
config.ErrNotFound? true
fs.ErrNotExist? false

Anyone reading the log still gets the full story, file name and all. Code calling Load gets exactly one thing to check, config.ErrNotFound, and that’s a promise we can keep however the config ends up being stored. The filesystem error is in the message, but it’s no longer in the chain, so callers can’t come to depend on it.

A word on the linters

If you run golangci-lint with err113 and its usual partner errorlint switched on, this file gets two complaints. Only the first wants fixing:

golangci-lint run --default none --enable err113,errorlint ./expose/...
expose/config/config.go:20:15: do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"loading config: %v\", err)" (err113)
expose/config/config.go:17:49: non-wrapping format verb for fmt.Errorf. Use `%w` to format errors (errorlint)

The err113 one is about the fallback on line 20, and it’s fair enough. It wants every error we return to have a sentinel somewhere in its chain, so callers always have something to check for, which is the argument this whole step has been making. The fix is to give that case a sentinel of its own (an ErrUnreadable, say) and wrap it with %w, just like ErrNotFound.

The errorlint one is about the %v on line 17, and it’s asking us to undo the very thing we just did deliberately. errorlint can’t tell a deliberate %v from a forgotten one, so when it’s deliberate we say so on the line itself:

return nil, fmt.Errorf("%w: %v", ErrNotFound, err) //nolint:errorlint // the cause is detail, not API

Keep the reason after the directive. Whoever reads that line next will want to know the %v wasn’t a slip, and the reason is what tells them.

Add a hint for the user with go/errors

Everything up to now has been about the program recognising an error. A second reader hasn’t come into it yet: the person at the terminal, who has just been told “config not found” and wants to know what to do about it. The tempting fix is to cram the advice into the message, and then the message gets long, changes whenever someone rewords the advice, and gets repeated word for word in each log line.

That’s the gap go/errors fills. It’s the error package my own Go modules are built on, and it sits on top of everything above rather than replacing it. Its Is, As, AsType and Join are the standard library’s own, so the chain we’ve just built behaves just as it did. What it adds is a handful of things that travel alongside the message as extra layers in the chain: a stack trace, detail for whoever is operating the program, structured slog attributes and, the piece we want here, a hint. A hint is the “what to do next”, kept separate from the “what went wrong”.

To try it, add the module (it depends on nothing outside the standard library):

go get gitlab.com/phpboyscout/go/[email protected]
go: upgraded go 1.26 => 1.26.6
go: added gitlab.com/phpboyscout/go/errors v0.3.0

That first line is Go raising your module’s minimum version to match the package’s, so don’t worry about it.

Now we take the expose step and give Load a hint. Create hint/config/config.go:

package config

import (
	"fmt"
	"io/fs"
	"os"

	"gitlab.com/phpboyscout/go/errors"
)

// ErrNotFound is returned when no config file exists at the given path.
var ErrNotFound = errors.NewSentinel("config.not_found", "config not found")

// Load reads the config file at path.
func Load(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return nil, errors.WithHintf(
			fmt.Errorf("%w: %v", ErrNotFound, err),
			"Run `myapp init` to create %s, or pass --config to point at another file.", path,
		)
	}
	if err != nil {
		return nil, fmt.Errorf("loading config: %v", err)
	}
	return data, nil
}

Two things have changed from the expose version. The sentinel is declared with NewSentinel, which is the package’s way of making a package-level sentinel without it capturing a pointless stack trace at start-up. And the error we return is wrapped in WithHintf, which adds the hint as one more layer.

Then hint/main.go. It deliberately checks the sentinel with the standard library’s errors.Is, to prove the hint hasn’t got in the way, and reads the hints back out with go/errors:

package main

import (
	"errors"
	"fmt"

	goerrors "gitlab.com/phpboyscout/go/errors"

	"example.com/errwrap/hint/config"
)

func main() {
	_, err := config.Load("missing.yaml")
	fmt.Println(err)
	fmt.Println("config.ErrNotFound?", errors.Is(err, config.ErrNotFound))
	for _, h := range goerrors.Hints(err) {
		fmt.Println("hint:", h)
	}
}
go run ./hint
config not found: open missing.yaml: no such file or directory
config.ErrNotFound? true
hint: Run `myapp init` to create missing.yaml, or pass --config to point at another file.

The message is word for word what it was in the expose step, so anything logging or matching on it won’t notice a difference, and the sentinel still matches through the standard library. That works because a hint is just another wrap, with an Unwrap method and an Error that passes its cause’s message straight through. It’s just carrying something extra along with it.

Hints also stack up as an error travels, outermost first, so the layer that knows most about what the user was trying to do gets the first word. Whatever prints the error at the edge of your program decides whether, and how, to show them.

Clean up

Everything lives in a single directory:

cd / && rm -rf /tmp/errwrap

Where to go next

The errors package docs are short and worth reading end to end now that the pieces have names. They also cover something we skipped: giving your own types Is or As methods, for when “the same error” means something looser than “the same value”.

Working with Errors in Go 1.13 is the original explanation of the whole chain, and the release notes for Go 1.20 and Go 1.26 cover Join and AsType.

For go/errors, attach a hint covers writing a good one (state the action, not the diagnosis), and hints, details and attributes explains where the line sits between the three. If you want the argument for why a hint deserves its own field at all, rather than a longer message, that’s an older post: errors that tell the user what to do next.

Built with Hugo · Theme Stack designed by Jimmy