Wrapping Errors with fmt.Errorf and %w

In Go, when a function fails deep inside a call chain, simply returning the raw error upward often loses valuable context about where and why the failure happened. The fmt.Errorf function lets you wrap an error with additional context while using the special %w verb to keep the original error reachable underneath the new one. This wrapped error can later be inspected with errors.Is, errors.As, and errors.Unwrap from the standard errors package, so callers can still detect a specific sentinel value or error type even after several layers of wrapping have added their own context. Mastering %w is essential for writing idiomatic, debuggable Go error handling.

Overview / How it works

Go has no exceptions. Errors are ordinary values of the built-in error interface, and the idiom is to return them explicitly and check them with if err != nil. The problem this creates is context: if a low-level function returns "file not found" and that bubbles up through five layers of callers unchanged, the caller at the top has no idea which file, which operation, or which layer failed. The traditional fix was to build a new string with fmt.Errorf("opening config: %v", err), but that throws away the original error value — you are left with a string that merely looks similar, and code can no longer test programmatically whether the original error was, say, os.ErrNotExist.

The %w verb, added in Go 1.13, solves this. When you write fmt.Errorf("opening config: %w", err), the returned error still formats as a string (the message includes the wrapped error’s text), but it also implements an Unwrap() error method that returns the original err value. This creates an error chain: a linked list of errors, each one wrapping the next, ending at some root cause. The errors package provides three functions that walk this chain for you:

  • errors.Unwrap(err) — returns the single error wrapped directly inside err, or nil if err doesn’t wrap anything.
  • errors.Is(err, target) — walks the whole chain (calling Unwrap repeatedly) asking at each step “is this equal to target, or does it have an Is(error) bool method that says so?” Use this to test against sentinel errors like sql.ErrNoRows or os.ErrNotExist.
  • errors.As(err, &target) — walks the chain looking for an error whose concrete type matches target‘s type, and if found, assigns it into target. Use this to recover a specific custom error type and read its fields.

Under the hood, fmt.Errorf with one %w verb returns a value of an unexported type from the fmt package that stores the formatted message string plus the wrapped error, and implements Unwrap() error. Since Go 1.20, you can use %w more than once in the same format string; in that case the returned error implements Unwrap() []error instead, and errors.Is/errors.As will search every branch. This is different from — but related to — errors.Join, which combines several independent errors into one without any format string at all. For the common case of adding context to a single failing call, one %w is what you want.

Syntax

wrapped := fmt.Errorf("context message: %w", err)
Part Meaning
"context message: %w" A format string. Anywhere in it, %w marks where the wrapped error’s text is substituted, exactly like %v would — but it also records the error value for unwrapping.
err Must be a non-nil value implementing the error interface. It becomes the error returned by Unwrap() on the result.
return type fmt.Errorf always returns a plain error. Its concrete type is unexported, but it satisfies an internal interface { Unwrap() error } (or Unwrap() []error for multiple %w verbs), which errors.Is and errors.As know how to use.

Examples

Example 1: wrapping and manually unwrapping

package main

import (
	"errors"
	"fmt"
)

func readConfig() error {
	return errors.New("file not found")
}

func loadApp() error {
	err := readConfig()
	if err != nil {
		return fmt.Errorf("loadApp: %w", err)
	}
	return nil
}

func main() {
	err := loadApp()
	if err != nil {
		fmt.Println(err)
		unwrapped := errors.Unwrap(err)
		fmt.Println(unwrapped)
	}
}

Output:

loadApp: file not found
file not found

readConfig returns a plain error. loadApp wraps it with %w, adding the context "loadApp: " to the message. Printing the wrapped error shows both pieces joined together, and errors.Unwrap peels off the outer layer to reveal the original error underneath, unchanged.

Example 2: testing for a sentinel error with errors.Is

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func findUser(id int) error {
	if id != 1 {
		return fmt.Errorf("findUser(%d): %w", id, ErrNotFound)
	}
	return nil
}

func main() {
	err := findUser(42)
	if errors.Is(err, ErrNotFound) {
		fmt.Println("user lookup failed: not found")
	}
	fmt.Println(err)
}

Output:

user lookup failed: not found
findUser(42): not found

ErrNotFound is a package-level sentinel value, a common Go pattern (compare to sql.ErrNoRows in the standard library). findUser wraps it with extra detail (the requested id). Even though the error returned from main‘s call is a different, wrapping value, errors.Is still recognizes that ErrNotFound is somewhere in its chain. A plain err == ErrNotFound comparison would have failed here, since err is not the same value as ErrNotFound — it’s a wrapper around it.

Example 3: recovering a custom error type with errors.As

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on field %q", e.Field)
}

func validateAge(age int) error {
	if age < 0 {
		return &ValidationError{Field: "age"}
	}
	return nil
}

func processForm(age int) error {
	if err := validateAge(age); err != nil {
		return fmt.Errorf("processForm: %w", err)
	}
	return nil
}

func main() {
	err := processForm(-5)
	var valErr *ValidationError
	if errors.As(err, &valErr) {
		fmt.Println("field:", valErr.Field)
	}
	fmt.Println(err)
}

Output:

field: age
processForm: validation failed on field "age"

ValidationError is a custom type carrying structured data (which field failed), not just a string. processForm wraps it the same way as before. errors.As walks the chain looking for an error whose concrete type is *ValidationError; when it finds one, it assigns it into valErr, giving the caller typed access to Field without needing to parse the error’s text.

How it works step by step

Walking through Example 1 shows the mechanics: (1) readConfig returns a basic error created by errors.New, which has no Unwrap method — it’s the end of the chain. (2) loadApp calls fmt.Errorf with %w; internally, fmt.Errorf formats the message string as usual, but because it saw %w, it also stores a reference to the original error and returns a value implementing Unwrap() error. (3) Back in main, fmt.Println(err) calls the wrapped error’s Error() method, which returns the pre-built message string — this is just string formatting, no unwrapping happens here. (4) errors.Unwrap(err) type-asserts that err implements interface{ Unwrap() error }, and if so, calls it, returning the original readConfig error. errors.Is and errors.As do the same type assertion in a loop, calling Unwrap repeatedly until they find a match or reach an error with no further Unwrap method.

Common Mistakes

Mistake 1: using %v instead of %w

It’s easy to type %v out of habit, since it also embeds the error’s text in the message. But %v does not preserve the chain — the resulting error has no Unwrap method, so errors.Is and errors.As can no longer see the original error at all.

func loadApp() error {
	err := readConfig()
	if err != nil {
		return fmt.Errorf("loadApp: %v", err)
	}
	return nil
}

Corrected, using %w so the chain survives:

func loadApp() error {
	err := readConfig()
	if err != nil {
		return fmt.Errorf("loadApp: %w", err)
	}
	return nil
}

Mistake 2: comparing wrapped errors with ==

Once an error has been wrapped even one layer, direct equality against a sentinel value will never match, because the wrapped value is a different concrete value from the sentinel. This mistake silently disables error handling — the branch simply never runs.

if err == ErrNotFound {
	fmt.Println("not found")
}

Corrected, using errors.Is so wrapping is accounted for:

if errors.Is(err, ErrNotFound) {
	fmt.Println("not found")
}

Best Practices

  • Use %w (not %v) whenever the error you’re returning should remain inspectable by callers further up the stack.
  • Keep wrap messages short and specific to the current function (e.g. "opening config file: %w"), since each layer up the stack adds its own prefix — the final message reads like a breadcrumb trail.
  • Define sentinel errors with errors.New as package-level vars (e.g. var ErrNotFound = errors.New("not found")) when callers need to test for a specific, known failure.
  • Define a custom error type (a struct implementing Error() string) when callers need structured data about the failure, not just a boolean check.
  • Always use errors.Is for sentinel comparisons and errors.As for type recovery — never == or a type switch on a possibly-wrapped error.
  • Don’t wrap errors you’re not adding context to; if a function has nothing useful to say, just return err unchanged.
  • Avoid wrapping the same error many times with near-identical messages across thin pass-through layers — it makes chains noisy without adding information.

Practice Exercises

  • Write a function divide(a, b int) (int, error) that returns a sentinel error ErrDivideByZero when b == 0. Write a caller that wraps this error with fmt.Errorf including the values of a and b, then use errors.Is to detect the division-by-zero case and print a friendly message instead of the raw error.
  • Define a custom error type *RangeError with Min and Max fields and an Error() string method. Write a function that returns it when a value is out of range, wrap it one level up with %w, and use errors.As in main to extract and print the Min/Max bounds.
  • Using Go 1.20+’s multiple-%w support, write a function that validates two independent fields and, if both fail, returns a single error wrapping both underlying errors with two %w verbs in one fmt.Errorf call. Confirm with errors.Is that both original errors are detectable in the result.

Summary

  • fmt.Errorf with %w wraps an error while adding context, preserving the original error in the resulting error’s chain.
  • errors.Unwrap retrieves the next error in the chain; errors.Is and errors.As walk the whole chain automatically.
  • Use errors.Is to test against a specific sentinel error value, and errors.As to recover a specific custom error type and its fields.
  • %v stringifies an error without preserving the chain; direct == comparison breaks once an error has been wrapped.
  • Since Go 1.20, a single fmt.Errorf call can wrap multiple errors using more than one %w verb.