Wrapping Errors with %w

fmt.Errorf with %w adds context to an error while keeping the original inside it, so later checks can still find it.

Syntaxfmt.Errorf(format string, a ...any) error // %w stores the wrapped error

An error from deep inside a program often has no context. "no such file or directory" does not say which file, or what the program was trying to do. Wrapping adds that context and keeps the original error inside the new one, so code further up can still ask what actually went wrong.

%w

fmt.Errorf("load config %s: %w", path, err) returns a new error. Its message is the formatted text. Stored inside it is the original err, still reachable. The %w verb was added in Go 1.13. Since Go 1.20 one format string may contain several %w verbs, which wraps several errors at once.

The chain

Each wrap adds a link: your error, then the error it wrapped, then the one that wrapped, down to the first. errors.Unwrap(err) returns the next error down, or nil at the end of the chain. errors.Is and errors.As walk the whole chain for you, which is the point of wrapping.

The longhand

%w is shorthand for a type of your own with two methods: Error() string for the message and Unwrap() error returning the inner error. Errorf builds a value like that. They are the same mechanism, so use the shorthand unless your error needs extra fields.

Writing the message

Add only your own layer, lowercase, with no trailing full stop and no "failed to". The chain already reads as a path: load config /etc/app.toml: open /etc/app.toml: no such file or directory. Never repeat what the inner error already says.

Example

Example · go
package main

import (
	"errors"
	"fmt"
)

// ErrNotFound is a fixed error value the whole package can report.
var ErrNotFound = errors.New("not found")

// lookup is the lowest layer. It knows nothing about users.
func lookup(id int) (string, error) {
	if id != 1 {
		return "", ErrNotFound
	}
	return "ada", nil
}

// loadUser adds context with %w, which keeps ErrNotFound inside the new error.
func loadUser(id int) (string, error) {
	name, err := lookup(id)
	if err != nil {
		return "", fmt.Errorf("load user %d: %w", id, err)
	}
	return name, nil
}

func main() {
	_, err := loadUser(7)
	fmt.Println(err)
	// errors.Is looks through the wrapping for the error we care about.
	fmt.Println("is ErrNotFound:", errors.Is(err, ErrNotFound))
	// Unwrap returns the error one layer down.
	fmt.Println("unwrapped:", errors.Unwrap(err))
}

// Output:
// load user 7: not found
// is ErrNotFound: true
// unwrapped: not found

When to use it

  • Turn "no such file or directory" into a message that names the config file you were loading.
  • Keep a database's not-found error findable while adding which user id was requested.
  • Report all the invalid fields of a submitted form as one error.

More examples

What %w expands to, written by hand

The two values behave the same, which is the whole point: %w is not magic, it builds a value with an Unwrap method. Write the type out by hand only when the error also needs fields of its own, such as a row number or a status code, that callers will read with errors.As.

Example · go
package main

import (
	"errors"
	"fmt"
)

// opError is the longhand of fmt.Errorf("op: %w", err): a message plus the
// error it wraps.
type opError struct {
	op  string
	err error
}

func (e *opError) Error() string { return e.op + ": " + e.err.Error() }

// Unwrap is the method errors.Is and errors.As follow to reach e.err.
func (e *opError) Unwrap() error { return e.err }

var ErrClosed = errors.New("connection closed")

func main() {
	hand := error(&opError{op: "write row", err: ErrClosed})
	short := fmt.Errorf("write row: %w", ErrClosed)

	fmt.Println(hand, "|", errors.Is(hand, ErrClosed))
	fmt.Println(short, "|", errors.Is(short, ErrClosed))
}

// Output:
// write row: connection closed | true
// write row: connection closed | true

Wrapping once per layer, and reading the path

Each layer adds one short phrase, so the final message reads outside-in like a trail to the cause, and the sentinel at the bottom is still findable. This is the normal shape of a log line in a Go service. The rule that keeps it readable: never repeat a word the inner error already printed.

Example · go
package main

import (
	"errors"
	"fmt"
)

var ErrPermission = errors.New("permission denied")

func readFile(path string) error {
	return fmt.Errorf("open %s: %w", path, ErrPermission)
}

func loadTemplate(name string) error {
	if err := readFile("/etc/app/" + name); err != nil {
		return fmt.Errorf("load template %q: %w", name, err)
	}
	return nil
}

func startup() error {
	if err := loadTemplate("index.html"); err != nil {
		return fmt.Errorf("startup: %w", err)
	}
	return nil
}

func main() {
	err := startup()
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrPermission))
}

// Output:
// startup: load template "index.html": open /etc/app/index.html: permission denied
// true

Wrapping several errors at once (Go 1.20)

Use errors.Join when a step has several independent failures and the caller should hear about all of them, such as validating every field of a form before answering. Both errors.Join and multiple %w verbs arrived in Go 1.20; on Go 1.19 and earlier they do not exist, so older code collects errors in a slice type instead.

Example · go
package main

import (
	"errors"
	"fmt"
)

var (
	ErrNoEmail = errors.New("email missing")
	ErrNoName  = errors.New("name missing")
)

func main() {
	// errors.Join collects errors into one. It returns nil if every argument
	// is nil, and its message puts each error on its own line.
	joined := errors.Join(ErrNoEmail, nil, ErrNoName)
	fmt.Println(joined)
	fmt.Println(errors.Is(joined, ErrNoName))

	// A format string may hold more than one %w since Go 1.20.
	both := fmt.Errorf("validate: %w and %w", ErrNoEmail, ErrNoName)
	fmt.Println(both)
	fmt.Println(errors.Is(both, ErrNoEmail), errors.Is(both, ErrNoName))
}

// Output:
// email missing
// name missing
// true
// validate: email missing and name missing
// true true

Discussion

  • Be the first to comment on this lesson.